Compare commits

...

37 Commits

Author SHA1 Message Date
Joseph Doherty e08b6b0e69 fix(alarms): populate ConditionClassId/ConditionClassName on conditions (#475)
MaterialiseAlarmCondition never assigned the mandatory Part 9 ConditionType
classification fields, so every condition event — native and scripted — shipped
ConditionClassId = NodeId.Null (i=0) and ConditionClassName = empty text. Same
mechanism as #473: Create() builds the mandatory children from the type's
embedded definition but leaves them unset, and nothing downstream synthesises
them (ReportEvent / InstanceStateSnapshot copy children verbatim). An HMI
bucketing alarms by condition class dropped every OtOpcUa alarm as unclassified.

Report BaseConditionClassType — Part 9's "no condition class modelled" value.
This is the honest report: we hold no classification at the materialise seam.
Deliberately NOT ProcessConditionClassType (the SDK sample's pick), which would
assert a classification we cannot back and would be actively wrong for a Galaxy
alarm whose upstream category is Safety/Diagnostics — trading a detectable null
for an undetectable lie. Real per-alarm classification needs the driver's
AlarmCategory carried to this deploy-time seam (it lives only on the runtime
AlarmEventArgs transition today) and is a separate feature.

Guards, both observed RED against the pre-fix server:
- NativeAlarmEventIdentityFieldDeliveryTests: wire-level, its own select clause
  (the #473 test's clause mirrors ScadaBridge's exactly and its indices are
  load-bearing, so it is left untouched). The class fields are declared on
  ConditionType, not BaseEventType, so they are selected against that type.
- NodeManagerAlarmSourceFieldsTests: node-level, native (Raw) + scripted (Uns).

Stacked on #473 (PR #474) — merge after it.
2026-07-17 00:49:43 -04:00
Joseph Doherty 7339a4af07 fix(alarms): populate EventType/SourceNode/SourceName on conditions (#473)
v2-ci / build (pull_request) Successful in 3m38s
v2-ci / unit-tests (pull_request) Failing after 9m38s
MaterialiseAlarmCondition never assigned the three mandatory BaseEventType
identity fields, so all three arrived null on every condition event — native
and scripted. The SDK does not synthesise them on this path: Create() builds
the children from the type definition but leaves them unset, the auto-filling
BaseEventState.Initialize overload is only used for transient events, and
ReportEvent / InstanceStateSnapshot copy children verbatim. A conforming
client could not attribute an alarm to its source.

  EventType  = the concrete materialised type (TypeDefinitionId)
  SourceNode = the condition's own NodeId (== ConditionId) — the condition IS
               the source; an alarm-bearing raw tag materialises only the
               condition, with no sibling value variable
  SourceName = the same identifying id string: RawPath (native) /
               ScriptedAlarmId (scripted)

SourceName carries the unique id rather than the leaf name: the leaf collides
across devices (HR200 on two PLCs) and is already carried by ConditionName, so
nothing is lost. Documented in docs/AlarmTracking.md, including that clients
must key on ConditionId and must not compose SourceName with ConditionName,
and that SourceName is NOT a live<->history join key (the alarm-history writer
stamps it with the EquipmentPath — a pre-existing divergence, now called out).

Tests: NativeAlarmEventIdentityFieldDeliveryTests is the wire-level guard —
a real client subscription using the standard [EventType, SourceNode,
SourceName, Time, Message, Severity] select clause, verified to fail against
the pre-fix server. NodeManagerAlarmSourceFieldsTests guards the node across
both realms, the base-type fallback, and the kind-swap re-materialise.

The HistoryRead events projection is a separate path (it projects historian
rows, not node fields) and is unaffected — its EventType => Variant.Null
assertions still hold.
2026-07-17 00:36:44 -04:00
Joseph Doherty 872cf7e37a test(secrets): register ISecretResolver in driver-factory resilience tests (Task 10 fix)
v2-ci / build (push) Successful in 3m36s
v2-ci / unit-tests (push) Failing after 8m35s
The full-suite gate caught a real regression: AddOtOpcUaDriverFactories' registration
now resolves ISecretResolver (for Galaxy/OpcUaClient secret: refs, Tasks 7-8), so the
two ResilienceInvokerFactoryRegistrationTests that build a minimal container and resolve
the invoker factory threw 'No service for ISecretResolver'. The real host always
registers it via AddZbSecrets (unconditional, Task 3), and GetRequiredService correctly
fails-fast for a genuinely misconfigured host — so the fix is to complete the test
container with a stub resolver, matching production composition. Test-only.
2026-07-16 18:27:59 -04:00
Joseph Doherty f1534920de test(secrets): G-2c guard AdminUI DriverConfig secret: ref round-trip (Task 9)
Verify + regression-guard that the AdminUI driver-config editor persists secret:/
env:/file: refs verbatim and never resolve-then-resaves cleartext (which would
re-leak the secret into the config store, defeating G-2). Confirmed structurally
safe: DriverConfig is persisted as an opaque JSON string via RawTreeService, which
has NO secret-resolver dependency (only the DbContext factory) — resolution is
impossible on the save path. Resolution stays confined to driver-instantiation /
Test-connect (Tasks 7-8); GalaxyDriverBrowser resolves into a connect-scoped local
only, never writing back. Three round-trip tests (OpcUaClient Password/UserCertPassword,
Galaxy ApiKeySecretRef, env:/file: forms) exercise the real RawTreeService seam and
assert byte-identical save→reload. No product change (no leak found). 662 AdminUI tests pass.
2026-07-16 18:19:24 -04:00
Joseph Doherty 9bb237b794 feat(secrets): G-2b resolve OpcUaClient secret: Password/UserCertificatePassword (Task 8)
Resolve secret:-prefixed Password + UserCertificatePassword through the shared
ISecretResolver, fail-closed on absent, retiring the cleartext-in-DB path. The
driver-registry factory is synchronous (Func<string,string,IDriver>), so resolution
is done lazily in the async session-open (InitializeAsync, before BuildUserIdentity)
rather than at deserialize — mirroring Task 7's Galaxy pattern and matching its
re-resolve-on-reconnect behavior. Both consumers (username Password and the
certificate-password LoadPkcs12 path via BuildCertificateIdentity) see the resolved
connect-scoped options; _options stays raw (secret: refs intact), no long-lived
plaintext field.

Scope corrections vs the plan (verified against v3): the probe is unauthenticated
GetEndpoints-only and never reads either credential, so it is NOT a resolution site
(comment added, no dead code); OpcUaClientDriverOptions was a sealed class, converted
to sealed record for the with-expression (no positional params → identical JSON; no
reference-equality/dict-key/ToString-log usages → no behavior/leak change).

ISecretResolver threaded via factory Register/CreateInstance + DriverFactoryBootstrap
(real resolver from DI); NullSecretResolver null-object backs test/parse paths only,
fail-closed on secret: refs. TDD: 4 helper tests RED→GREEN; 142 OpcUaClient tests pass.
2026-07-16 18:14:12 -04:00
Joseph Doherty 1424a21419 feat(secrets): G-2a secret: arm on GalaxySecretRef via ISecretResolver (Task 7)
Add a secret:NAME arm to GalaxySecretRef.ResolveApiKey that resolves the Galaxy
gateway API key through the shared ISecretResolver — fail-closed if the secret is
absent (never falls through to the cleartext literal arm), retiring the dev:/literal
in-DB path for production. Because GetAsync is async the method becomes
ResolveApiKeyAsync; the await cascade threads ISecretResolver by ctor injection into
GalaxyDriver + GalaxyDriverBrowser and (since GalaxyDriver is built by a static
factory closure, not DI) through GalaxyDriverFactoryExtensions + DriverFactoryBootstrap
(which pulls the real resolver from the service provider — registered unconditionally
in Slice 1). A NullSecretResolver null-object backs the parse-only/test paths only;
the runtime path always gets the real resolver (verified end-to-end).

TDD: 3 new secret:-arm tests (resolve / fail-closed-on-absent / no-literal-warning)
RED without the arm, GREEN with it; 338 Galaxy tests pass; no sync-over-async.
2026-07-16 17:57:04 -04:00
Joseph Doherty ce383df39a docs(secrets): G-5 clustered master-key posture runbook (Task 6)
OtOpcUa is Akka-clustered and AddZbSecrets is registered unconditionally, so every
node (admin, driver, fused) resolves secrets and needs the SAME KEK + same store rows.
Ship G-5 as a production-posture runbook rather than hardcoding Source=File + shared
paths into the committed role-overlay appsettings (which dev + TwoNodeClusterHarness
also consume — that would break every dev/CI boot). Base appsettings stays on
Source=Environment. Documents the interim File-KEK + shared-SQLite posture and the
G-7 hand-off (ConfigDb-backed ISecretStore mirroring the existing DP
PersistKeysToDbContext<OtOpcUaConfigDbContext> key-ring sharing). Cross-linked from
docs/security.md. Mirrors the ScadaBridge G-5 resolution.
2026-07-16 17:30:32 -04:00
Joseph Doherty a0be76b5f0 feat(secrets): mount /admin/secrets UI + secrets authorization (Task 5, G-6)
Mount the shared ZB.MOM.WW.Secrets.Ui RCL page at /admin/secrets on admin nodes:
- Register secrets:manage/secrets:reveal policies additively via
  Configure<AuthorizationOptions>(o => o.AddSecretsAuthorization()) in AddAdminUI
  (the admin-only composition layer that already references the RCL — avoids forcing
  an RCL dependency into the core Security lib; mirrors HistorianGateway)
- Register the RCL routable assembly in BOTH the SSR endpoint (AddAdditionalAssemblies)
  and the interactive Router (App.razor AdditionalAssemblies) or the route 404s
- Add a Secrets nav item; the page's own [Authorize(Policy=...)] gates access

Claim-type MATCH: AdminRole=Administrator reads the same ClaimTypes.Role as FleetAdmin,
so existing Administrators are authorized with no new role mapping. Full clean boot
verified; interactive reveal deferred to the live gate (shared UI already proven).
2026-07-16 17:26:54 -04:00
Joseph Doherty 73d8439412 docs(secrets): G-4 ${secret:} config delivery convention + fail-closed proof (Task 4)
OtOpcUa commits no plaintext secrets — the only secret-shaped committed value is
ServerHistorian:ApiKey='' (empty, disabled by default). Because the pre-host expander
is fail-closed AND section-agnostic, hard-committing a ${secret:} token would break
every dev/CI/integration boot for zero at-rest benefit. Instead document the token
delivery convention for all five config secrets (jwt/ldap/deploy/configdb/historian)
in docs/security.md and note the token option in the ServerHistorian comments.

Mechanism proven live: unseeded token boot fails with SecretNotFoundException at
pre-host expansion; seeded token resolves and boot proceeds. Behavior-neutral
(comment + doc only).
2026-07-16 17:14:32 -04:00
Joseph Doherty 8843418c54 feat(secrets): register AddZbSecrets unconditionally on the host (Task 3)
Runtime ISecretResolver must be present on every clustered node regardless of
role: driver-only nodes resolve Layer-B DriverConfig secret: refs but have no
auth/DP/AdminUI (all admin-role-gated). Placed in the unconditional flow next to
AddOtOpcUaHealth(), outside both role blocks. Data Protection left untouched.
2026-07-16 17:07:41 -04:00
Joseph Doherty 772d3a5f34 feat(secrets): add ZB.MOM.WW.Secrets refs + pre-host ${secret:} expander (Tasks 1-2)
Adopt the ZB.MOM.WW.Secrets library (0.1.2, Gitea feed) into OtOpcUa under CPM:
- Directory.Packages.props + NuGet.config source mapping for the three packages
- Host/AdminUI/Driver.Galaxy.Contracts/Driver.OpcUaClient package references
- Layer-A pre-host ${secret:} config expander in Host/Program.cs, placed after all
  config providers assemble and before the first config read (mechanism only,
  behavior-neutral no-op until Task 4 introduces tokens)
- Secrets section in appsettings.json (env-var KEK, SQLite store, 30s cache TTL)

Mechanism mirrors the proven HistorianGateway adoption. KEK never committed.
2026-07-16 17:03:16 -04:00
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
120 changed files with 7325 additions and 1517 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
+3
View File
@@ -132,6 +132,9 @@
<PackageVersion Include="ZB.MOM.WW.Auth.Abstractions" Version="0.1.1" />
<PackageVersion Include="ZB.MOM.WW.Auth.Ldap" Version="0.1.1" />
<PackageVersion Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.1" />
<PackageVersion Include="ZB.MOM.WW.Secrets" Version="0.1.2" />
<PackageVersion Include="ZB.MOM.WW.Secrets.Abstractions" Version="0.1.2" />
<PackageVersion Include="ZB.MOM.WW.Secrets.Ui" Version="0.1.2" />
<PackageVersion Include="ZB.MOM.WW.Audit" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Theme" Version="0.3.1" />
<PackageVersion Include="ZB.MOM.WW.HistorianGateway.Client" Version="0.3.0" />
+2
View File
@@ -25,6 +25,8 @@
<package pattern="ZB.MOM.WW.Theme" />
<package pattern="ZB.MOM.WW.HistorianGateway.Contracts" />
<package pattern="ZB.MOM.WW.HistorianGateway.Client" />
<package pattern="ZB.MOM.WW.Secrets" />
<package pattern="ZB.MOM.WW.Secrets.*" />
</packageSource>
</packageSourceMapping>
</configuration>
+87
View File
@@ -24,6 +24,93 @@ 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.
## Condition event identity fields (what a client reads on the wire)
Every condition event — native and scripted — carries the mandatory `BaseEventType` identity
fields, assigned at materialize time in `OtOpcUaNodeManager.MaterialiseAlarmCondition`. The SDK
does **not** synthesize them on this path (`Create` builds the children from the type definition
but leaves them unset; `ReportEvent` / `InstanceStateSnapshot` copy children verbatim), so they
are set explicitly. Leaving them unset shipped them as **null** on every event — see issues #473
(the `BaseEventType` trio) and #475 (the `ConditionType` classification pair).
| Field | Value | Notes |
|---|---|---|
| `EventType` | the **concrete** materialized type (`TypeDefinitionId`) | e.g. `OffNormalAlarmType`; falls back to `AlarmConditionType` for an unknown authored type. Readable as a *field*, not only via an `OfType` where-clause |
| `SourceNode` | the condition's **own NodeId** — equal to `ConditionId` | The condition **is** the source: an alarm-bearing raw tag materializes only the condition, with no sibling value variable, so there is no other node to point at |
| `SourceName` | the same identifying id string: **RawPath** (native) / **ScriptedAlarmId** (scripted) | Deliberately the *unique* id, **not** the leaf name |
| `ConditionName` | the leaf / display name (e.g. `HR200`) | Where the short human-readable name lives |
| `ConditionClassId` | always **`BaseConditionClassType`** | Part 9's "no condition class modelled" value. Unset shipped `NodeId.Null` (#475) |
| `ConditionClassName` | always **`"BaseConditionClass"`** | Matches `ConditionClassId`. Unset shipped empty text (#475) |
**Why `BaseConditionClassType` and not `ProcessConditionClassType`.** We hold no per-alarm
classification at the materialize seam, and `ConditionClassId` is a wire contract clients bucket on.
`BaseConditionClassType` is the honest, spec-conformant report of *"this server does not model
condition classes"* — it fixes the real defect (a null that breaks conformant clients) without
asserting a classification we cannot back. `ProcessConditionClassType` — the SDK sample's pick —
was rejected deliberately: it would be *actively wrong* for a Galaxy alarm whose upstream category is
Safety or Diagnostics, trading a detectable null for an undetectable lie. Real per-alarm
classification is a separate future feature: it needs the driver's alarm category, which today lives
only on the runtime `AlarmEventArgs` transition, carried to the deploy-time authored composition that
`MaterialiseAlarmCondition` sees. Until then the `IAlarmSource` doc comment claiming the category
"maps to `ConditionClassName` downstream" describes an intent, not the implementation.
**Why `SourceName` is the id, not the leaf name.** The leaf is ambiguous across devices (`HR200` on
two PLCs collides) and is already carried by `ConditionName`, so the leaf-name option would add no
identity while costing uniqueness. Carrying the id makes the `SourceNode`/`SourceName`/`ConditionId`
triple mutually consistent and unique. This diverges from the loose OPC UA convention that
`SourceName` mirrors the source node's BrowseName; the divergence is intentional.
**Client guidance:** key on **`ConditionId`** — the condition node's own NodeId, which equals
`SourceNode`, and whose identifier is the RawPath for native alarms and the ScriptedAlarmId for
scripted ones. It is the identity ack/confirm/shelve route on. `SourceName` carries the same
identifier and is unique, so it is safe to key on *by itself* — but do **not** compose it with
`ConditionName` (`$"{SourceName}.{ConditionName}"`), because `SourceName` already ends in the
condition's leaf name and the result stutters (`pymodbus/plc/HR200.HR200`).
Wire-level guard: `NativeAlarmEventIdentityFieldDeliveryTests` asserts the three `BaseEventType`
fields arrive populated on a real subscription using the standard `[EventType, SourceNode,
SourceName, Time, Message, Severity]` select clause, and — in a second test with its own clause —
that `ConditionClassId` / `ConditionClassName` do too. The two class fields are declared on
`ConditionType`, **not** `BaseEventType`, so a client must select them against that type.
`NodeManagerAlarmSourceFieldsTests` guards the node itself across both realms.
> **Do not correlate live events to HistoryRead on `SourceName` — the two paths disagree.**
> The HistoryRead *events* projection (`OtOpcUaNodeManager.ProjectEventField`) returns
> `Variant.Null` for `EventType` / `SourceNode` **by design**: it projects from the historian's
> `HistoricalEvent` rows, which do not carry them. It **does** project `SourceName` — but the
> alarm-history writer stamps that field with the **EquipmentPath**
> (`AlarmEventMapper`: `SourceName = alarm.EquipmentPath`), not the RawPath a live event carries.
> So `SourceName` is the one field populated on both paths **with different values**, and it is not
> a live↔history join key. Correlate on `ConditionId` / the RawPath instead. (Pre-existing; the
> live-path fix above does not change the history path.)
## 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).
@@ -0,0 +1,177 @@
# Secrets: Clustered Master-Key Posture (All Roles)
## Purpose
`ZB.MOM.WW.Secrets` resolves `${secret:...}` tokens in `appsettings.*.json` via a
pre-host expander (`SecretReferenceExpander.ExpandConfigurationAsync`, wired in
`src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs`) that runs at **every** OtOpcUa node
boot, before the host is built. It reads rows from an envelope-encrypted SQLite
store (`Secrets:SqlitePath`) unwrapped with a key-encryption key (KEK) sourced per
`Secrets:MasterKey:Source`. The runtime `ISecretResolver` (`AddZbSecrets`) is also
registered unconditionally, independent of node role.
OtOpcUa is Akka.NET-clustered (`builder.Services.AddAkka("otopcua", (ab, sp) => { ... })`
in `Program.cs`), with roles parsed by `RoleParser` (`src/Core/ZB.MOM.WW.OtOpcUa.Cluster/RoleParser.cs`):
`admin`, `driver`, `dev`. A node can carry any combination of these roles (e.g. a
fused admin+driver node, or a driver-only node). This runbook covers what a
production deployment needs so that secret resolution behaves identically on
**every node regardless of role** — not just admin nodes. That matters here more
than it might elsewhere: the pre-host expander and the runtime resolver both run
unconditionally on driver-only nodes too, and driver-only nodes are the ones that
will resolve Layer-B `DriverConfig` secret references (coming in Slice 2) at
runtime, not just at boot. It does **not** change any code; it is an
operations/deployment posture, delivered out-of-band from the committed config.
## The two hard requirements
For the pre-host expander (and the runtime resolver) to resolve the same
plaintext secret on every node, no matter its role:
1. **Identical KEK on every node.** All nodes — admin, driver, and any fused
combination — must unwrap the store with the exact same master key. A
per-node KEK (e.g. a per-box DPAPI-protected key) would make each node
decrypt every *other* node's ciphertext rows to garbage.
2. **Identical store rows on every node.** All nodes must read the same SQLite
database (same file, or a replicated/shared copy with the same rows) — not
independently-seeded stores that happen to share a KEK.
`ZB.MOM.WW.Secrets` ships a SQLite-only `ISecretStore` with a `NoOpSecretReplicator`
— there is no built-in cross-node replication today. Meeting both requirements in
production is a deployment concern, covered below.
## Recommended interim posture (G-5)
Until real replication exists (G-7, below), the recommended production posture is:
- **`Secrets:MasterKey:Source = File`**, with `FilePath` pointing at a **read-only
key file that is identical on every node of every role** — a base64-encoded
32-byte key, generated out-of-band (e.g. `openssl rand -base64 32`), distributed
to each node's filesystem/secret-mount by the deployment tooling, and **never
committed** to the repo. Treat it with the same discipline as any other
production secret (restrictive file ACLs, no logging, rotated via a future
KEK-rotation runbook — not yet built).
- **`Secrets:SqlitePath`** pointing at a **single shared or replicated volume**
that every node mounts (admin, driver, and dev/fused alike), so every node's
migrator opens and reads the same rows at boot.
Writes to the store are rare and human-driven — an operator using the
`/admin/secrets` UI (admin nodes only) or the `ZB.MOM.WW.Secrets` CLI — while
reads happen on every node's boot and on the `ResolveCacheTtl` refresh cycle,
regardless of role. The access pattern is read-mostly / effectively
single-writer, which is what makes a shared SQLite volume viable as an interim
posture (see caveat below).
## How it's delivered (do NOT commit these values)
The File-KEK + shared-store posture is supplied per-node at deployment time —
**never** by editing the committed `appsettings.json` or the role-overlay files
(`appsettings.admin.json`, `appsettings.driver.json`, `appsettings.admin-driver.json`).
Those committed overlays are also consumed by local dev and the
`TwoNodeClusterHarness` integration tests, so hardcoding a `Source=File` path
into them would break every dev/test/CI boot. Two acceptable delivery
mechanisms instead:
**Option A — environment variable overrides** (Windows Service / NSSM env block,
container `env_file`, etc.), applied identically on every node regardless of role:
```
# production deployment — do not commit to the dev appsettings
Secrets__MasterKey__Source=File
Secrets__MasterKey__FilePath=/run/secrets/otopcua-master.key
Secrets__SqlitePath=/shared/secrets/otopcua-secrets.db
```
**Option B — a production-only config layer** that is *not* the committed dev
base (e.g. an untracked `appsettings.Production.json` deployed alongside the
binaries, or an orchestrator-injected config mount):
```jsonc
// production deployment — do not commit to the dev appsettings
{
"Secrets": {
"MasterKey": {
"Source": "File",
"FilePath": "/run/secrets/otopcua-master.key"
},
"SqlitePath": "/shared/secrets/otopcua-secrets.db"
}
}
```
Either way, the file/path referenced must exist and be identical on every node
**before** that node boots — the pre-host expander runs unconditionally on every
role and will throw (`SecretNotFoundException` / migration failure) if the store
or key is missing.
## Caveat: SQLite over a shared volume is not real replication
SQLite's file-locking model does not tolerate concurrent multi-writer access well
over network filesystems (SMB/NFS locking is unreliable, and even on a clustered
block volume only one writer should be active at a time). The interim posture
above is acceptable because:
- Reads dominate (every node's boot + cache-refresh cycle, across every role).
- Writes are rare, human-initiated, and effectively single-writer in practice
(an operator runs the CLI/UI against one admin node at a time).
It is **not** a substitute for real replication, and it is not safe if multiple
nodes attempt concurrent writes. Do not build automation that writes secrets
from more than one node simultaneously.
## Data Protection is independent — do not touch it here
OtOpcUa's cookie/session protection already has its own clustered-key story:
`AddDataProtection().PersistKeysToDbContext<OtOpcUaConfigDbContext>()`
(`src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs:73-75`),
which shares the Data Protection key ring across every node via the existing
ConfigDb. That mechanism is unrelated to `ZB.MOM.WW.Secrets`' envelope encryption
(KEK + SQLite store) and must **not** be reconfigured as part of secrets-adoption
work — doing so risks invalidating active sessions for an unrelated reason.
It is, however, the model for where the *next* iteration of secret storage
should go — see the G-7 hand-off below.
## The G-7 hand-off
The posture above is an interim, ops-only workaround. The long-term shape,
tracked as **G-7** in `scadaproj/components/secrets/GAPS.md`, is a
ConfigDb-backed `ISecretStore` that mirrors the pattern OtOpcUa already uses for
the Data Protection key ring:
```csharp
services.AddDataProtection()
.PersistKeysToDbContext<OtOpcUaConfigDbContext>()
.SetApplicationName("OtOpcUa");
```
(`src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs:73-75`).
`OtOpcUaConfigDbContext` already gives every node — admin, driver, and dev/fused
alike — a single MS SQL-backed source of truth for the Data Protection key ring;
the secret store is the natural next tenant of that same shared database instead
of a shared SQLite file. Building this requires new `ZB.MOM.WW.Secrets` library
code (a ConfigDb-backed `ISecretStore` implementation) that does not exist yet,
overlaps the G-7 tracking item, and is explicitly **deferred there** — it is not
built as part of this cut. This runbook's shared-SQLite-volume posture is the
bridge until G-7 lands.
## Dev/test/default posture (unchanged)
The committed default in `appsettings.json` is:
```json
"Secrets": {
"SqlitePath": "otopcua-secrets.db",
"MasterKey": { "Source": "Environment", "EnvVarName": "ZB_SECRETS_MASTER_KEY" },
"RunMigrationsOnStartup": true
}
```
This is dev-safe: `Source=Environment` needs no filesystem key, and the SQLite
path is relative to the working directory, so local dev, the role-overlay
appsettings (`appsettings.admin.json`, `appsettings.driver.json`,
`appsettings.admin-driver.json`), and the `TwoNodeClusterHarness` integration
tests all boot cleanly with no external mount. The File-KEK + shared-volume
posture in this runbook applies only to real clustered production deployments —
it must never be baked into the committed dev/role-overlay base, because the
expander runs unconditionally at every node boot (any role) and would break
dev/CI if pointed at a nonexistent `/shared` mount.
+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
+49
View File
@@ -380,6 +380,55 @@ polling the node.
---
## Config secrets (`${secret:}` delivery)
OtOpcUa never commits secret values to `appsettings*.json`. Every real secret is
supplied at deploy time — either as a plain environment variable, or as a
`${secret:<name>}` token backed by the shared **`ZB.MOM.WW.Secrets`** encrypted store.
A pre-host expander (`SecretReferenceExpander.ExpandConfigurationAsync`, wired in
`OtOpcUa.Host/Program.cs`) walks the assembled configuration and rewrites every
`${secret:<name>}` token into its resolved plaintext **before** the host is built —
so every downstream binder/validator (`AddZbSerilog`, `AddOtOpcUaConfigDb`, the first
`ValidateOnStart`) sees resolved values. The store's key-encryption key (KEK) comes from
the `ZB_SECRETS_MASTER_KEY` environment variable (`Secrets:MasterKey:Source=Environment`);
the encrypted SQLite store lives at `Secrets:SqlitePath`.
The expander is **fail-closed and section-agnostic**: a `${secret:<name>}` token whose
secret is absent throws `SecretNotFoundException` at startup, regardless of which feature
owns the key or whether that feature is enabled. Keys prefixed with `_` (comment keys) are
skipped, so a `${secret:...}` example inside a `_…Comment` value is never resolved.
The five config secrets and their canonical secret names:
| Config key | Owner | Secret name |
|---|---|---|
| `Security:Jwt:SigningKey` | `JwtOptions` | `otopcua/jwt/signing-key` |
| `Security:Ldap:ServiceAccountPassword` | `LdapOptions` | `otopcua/ldap/service-account-password` |
| `Security:DeployApiKey` | `DeployApiEndpoints` | `otopcua/deploy/api-key` |
| `ConnectionStrings:ConfigDb` | `AddOtOpcUaConfigDb` | `otopcua/sql/configdb-connstr` |
| `ServerHistorian:ApiKey` | `ServerHistorianOptions` | `otopcua/historian/api-key` |
**Delivery.** By default these are delivered as plain environment variables (e.g.
`ServerHistorian__ApiKey=histgw_…`), never committed. To deliver one from the encrypted
store instead, seed it once with the `secret` CLI (from `ZB.MOM.WW.Secrets`), then supply
the token in place of the literal — via env var or a deployment appsettings overlay:
```
secret set otopcua/historian/api-key <value> # seed the encrypted store first
ServerHistorian__ApiKey='${secret:otopcua/historian/api-key}'
```
Only switch a value to a `${secret:}` token **after** the secret is seeded — an unseeded
token fails the boot fail-closed. Do not commit the KEK (`ZB_SECRETS_MASTER_KEY`) or any
secret value.
For the production posture needed so every clustered node (admin, driver, and any
fused role) resolves the same secrets from the same store, see
[`docs/operations/2026-07-16-secrets-clustered-master-key.md`](operations/2026-07-16-secrets-clustered-master-key.md).
---
## Troubleshooting
### Certificate trust failure
@@ -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);
}
}
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
@@ -34,11 +35,14 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
};
private readonly ILogger<GalaxyDriverBrowser> _logger;
private readonly ISecretResolver _secretResolver;
/// <summary>Creates a new browser. Logger defaults to <see cref="NullLogger{T}"/>.</summary>
/// <param name="secretResolver">Resolves the <c>secret:</c> arm of <c>Gateway.ApiKeySecretRef</c> (DI-injected).</param>
/// <param name="logger">Optional logger; null is allowed for unit-test construction.</param>
public GalaxyDriverBrowser(ILogger<GalaxyDriverBrowser>? logger = null)
public GalaxyDriverBrowser(ISecretResolver secretResolver, ILogger<GalaxyDriverBrowser>? logger = null)
{
_secretResolver = secretResolver ?? throw new ArgumentNullException(nameof(secretResolver));
_logger = logger ?? NullLogger<GalaxyDriverBrowser>.Instance;
}
@@ -68,7 +72,7 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
if (string.IsNullOrWhiteSpace(opts.MxAccess.ClientName))
throw new InvalidOperationException("Galaxy browser requires MxAccess.ClientName.");
var clientOpts = BuildClientOptions(opts.Gateway);
var clientOpts = await BuildClientOptionsAsync(opts.Gateway, cancellationToken).ConfigureAwait(false);
// 30s wall-clock budget for the connect phase, linked to the caller's token so
// an AdminUI cancel still wins early.
@@ -116,19 +120,28 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
/// Build the gateway client options from the form's Gateway section. Mirrors the
/// runtime driver's <c>GalaxyDriver.BuildClientOptions</c> field-for-field so the
/// gateway sees an identical option shape. The API-key reference is resolved via
/// the shared <see cref="GalaxySecretRef.ResolveApiKey"/> in Driver.Galaxy.Contracts
/// the shared <see cref="GalaxySecretRef.ResolveApiKeyAsync"/> in Driver.Galaxy.Contracts
/// (the same resolver the runtime driver uses), so browse and runtime stay in lock-step.
/// The <c>secret:</c> arm is async, so the key is resolved into a local before the
/// object initializer (you can't await inside one).
/// </summary>
private MxGatewayClientOptions BuildClientOptions(GalaxyGatewayOptions gw) => new()
private async Task<MxGatewayClientOptions> BuildClientOptionsAsync(
GalaxyGatewayOptions gw, CancellationToken cancellationToken)
{
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
ApiKey = GalaxySecretRef.ResolveApiKey(gw.ApiKeySecretRef, _logger),
UseTls = gw.UseTls,
CaCertificatePath = gw.CaCertificatePath,
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds),
StreamTimeout = gw.StreamTimeoutSeconds > 0
? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds)
: null,
};
var apiKey = await GalaxySecretRef
.ResolveApiKeyAsync(gw.ApiKeySecretRef, _secretResolver, _logger, cancellationToken)
.ConfigureAwait(false);
return new MxGatewayClientOptions
{
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
ApiKey = apiKey,
UseTls = gw.UseTls,
CaCertificatePath = gw.CaCertificatePath,
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds),
StreamTimeout = gw.StreamTimeoutSeconds > 0
? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds)
: null,
};
}
}
@@ -43,12 +43,14 @@ public sealed record GalaxyDriverOptions(
/// <summary>
/// Connection details for the MxAccess gateway. <see cref="ApiKeySecretRef"/> is
/// resolved by <see cref="GalaxySecretRef.ResolveApiKey"/> at InitializeAsync time. Four forms
/// resolved by <see cref="GalaxySecretRef.ResolveApiKeyAsync"/> at InitializeAsync time. Five forms
/// supported, in priority order:
/// <list type="bullet">
/// <item><c>env:NAME</c> — read from an environment variable (recommended for
/// production; the central config DB holds only the indirection, not the key).</item>
/// <item><c>file:PATH</c> — read from an ACL'd file outside the repo.</item>
/// <item><c>secret:NAME</c> — resolved through the shared <c>ZB.MOM.WW.Secrets</c>
/// encrypted store; fail-closed if absent (the production path).</item>
/// <item><c>dev:KEY</c> — explicit cleartext opt-in for dev rigs / parity tests;
/// no startup warning.</item>
/// <item>Anything else — treated as a literal cleartext API key for back-compat.
@@ -1,9 +1,10 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
/// <summary>
/// Resolves <c>Gateway.ApiKeySecretRef</c> to the actual API-key string. Four
/// Resolves <c>Gateway.ApiKeySecretRef</c> to the actual API-key string. Five
/// forms supported, evaluated in order:
/// <list type="number">
/// <item><c>env:NAME</c> — reads <c>Environment.GetEnvironmentVariable(NAME)</c>.
@@ -15,12 +16,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
/// <item><c>dev:KEY</c> — explicit cleartext literal. The <c>dev:</c> prefix
/// is a deliberate opt-in signal (dev box, parity rig) so the resolver
/// doesn't emit a warning; production should never use this arm.</item>
/// <item><c>secret:NAME</c> — resolves NAME through the shared
/// <c>ZB.MOM.WW.Secrets</c> <see cref="ISecretResolver"/> (the encrypted-at-rest
/// store). Fail-closed: a <c>secret:</c> ref whose secret is absent/tombstoned
/// throws rather than falling through to the literal arm — the production path
/// that retires the cleartext <c>dev:</c>/literal-in-DB model.</item>
/// <item>Anything else — used as the literal API key for back-compat with
/// configs that pre-date this resolver. When a logger is supplied the
/// resolver emits a startup warning so an operator who accidentally
/// committed a cleartext key sees it.</item>
/// </list>
/// A future PR can swap any of these arms for a DPAPI-backed lookup without
/// A future PR can swap any of these arms for a different backing store without
/// changing the call site.
/// </summary>
/// <remarks>
@@ -31,18 +37,26 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
public static class GalaxySecretRef
{
/// <summary>
/// Resolves the supplied secret reference. When the ref falls through to the
/// back-compat literal arm (an unprefixed cleartext API key in
/// <c>DriverConfig</c> JSON) and a <paramref name="logger"/> is supplied, emits
/// a <see cref="LogLevel.Warning"/>. The <c>dev:</c> prefix is the explicit
/// opt-in path that doesn't warn.
/// Resolves the supplied secret reference. The <c>secret:NAME</c> arm resolves
/// through <paramref name="resolver"/> and is fail-closed (throws when the secret
/// is absent). When the ref falls through to the back-compat literal arm (an
/// unprefixed cleartext API key in <c>DriverConfig</c> JSON) and a
/// <paramref name="logger"/> is supplied, emits a <see cref="LogLevel.Warning"/>.
/// The <c>dev:</c> prefix is the explicit opt-in path that doesn't warn.
/// </summary>
/// <param name="secretRef">The secret reference string to resolve.</param>
/// <param name="resolver">The shared secret resolver used by the <c>secret:</c> arm.</param>
/// <param name="logger">Optional logger for warning on cleartext keys.</param>
/// <param name="ct">Cancellation token for the async <c>secret:</c> resolution.</param>
/// <returns>The resolved API-key string.</returns>
public static string ResolveApiKey(string secretRef, ILogger? logger = null)
public static async Task<string> ResolveApiKeyAsync(
string secretRef,
ISecretResolver resolver,
ILogger? logger = null,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrEmpty(secretRef);
ArgumentNullException.ThrowIfNull(resolver);
if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
{
@@ -76,6 +90,19 @@ public static class GalaxySecretRef
return secretRef[4..];
}
if (secretRef.StartsWith("secret:", StringComparison.OrdinalIgnoreCase))
{
// Production path: resolve the name through the shared encrypted secret store.
// Fail-closed — an absent/tombstoned secret throws rather than falling through
// to the literal arm (which would silently treat the ref string as the key).
var name = secretRef["secret:".Length..];
var value = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
return !string.IsNullOrEmpty(value)
? value
: throw new InvalidOperationException(
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' resolves secret '{name}', but it is absent from the store (fail-closed).");
}
// Back-compat literal arm. An unprefixed string is treated as the literal
// API key — but emit a warning so an operator who accidentally committed a
// cleartext key into DriverConfig sees it. Use the dev: prefix to suppress
@@ -13,5 +13,6 @@
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj" />
<!-- Logging abstraction needed by GalaxySecretRef.ResolveApiKey's optional warning logger. -->
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions" />
</ItemGroup>
</Project>
@@ -8,6 +8,7 @@ using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Health;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
@@ -43,6 +44,12 @@ public sealed class GalaxyDriver
private readonly GalaxyDriverOptions _options;
private readonly ILogger<GalaxyDriver> _logger;
// Resolves the Gateway.ApiKeySecretRef secret: arm through the shared encrypted store.
// Injected via ctor (the production factory pulls it from DI). The internal test ctor
// defaults it to a null-object resolver so the 45 seam-injecting test call sites keep
// compiling; those tests never use a secret: ref (they inject seams or use env/file/literal).
private readonly ISecretResolver _secretResolver;
// PR 4.1 — IGalaxyHierarchySource is the test seam for browse. When null, the driver
// lazily builds a GatewayGalaxyHierarchySource around a GalaxyRepositoryClient on
// first DiscoverAsync. Tests inject a fake source via the internal ctor to exercise
@@ -147,14 +154,17 @@ public sealed class GalaxyDriver
/// <summary>Initializes a new instance of the <see cref="GalaxyDriver"/> class.</summary>
/// <param name="driverInstanceId">The unique identifier for this driver instance.</param>
/// <param name="options">The Galaxy driver configuration options.</param>
/// <param name="secretResolver">Resolves the <c>secret:</c> arm of <c>Gateway.ApiKeySecretRef</c>.</param>
/// <param name="logger">Optional logger instance for diagnostics.</param>
public GalaxyDriver(
string driverInstanceId,
GalaxyDriverOptions options,
ISecretResolver secretResolver,
ILogger<GalaxyDriver>? logger = null)
: this(driverInstanceId, options,
hierarchySource: null, dataReader: null, dataWriter: null, subscriber: null,
alarmAcknowledger: null, alarmFeed: null, logger)
alarmAcknowledger: null, alarmFeed: null, logger,
secretResolver: secretResolver ?? throw new ArgumentNullException(nameof(secretResolver)))
{
}
@@ -173,6 +183,11 @@ public sealed class GalaxyDriver
/// <param name="alarmAcknowledger">Optional custom alarm acknowledger for testing.</param>
/// <param name="alarmFeed">Optional custom alarm feed for testing.</param>
/// <param name="logger">Optional logger instance for diagnostics.</param>
/// <param name="secretResolver">
/// Optional secret resolver for the <c>secret:</c> API-key arm. Defaults to a
/// null-object resolver (returns null for every name) so seam-injecting tests that
/// don't exercise a <c>secret:</c> ref need not supply one.
/// </param>
internal GalaxyDriver(
string driverInstanceId,
GalaxyDriverOptions options,
@@ -182,13 +197,15 @@ public sealed class GalaxyDriver
IGalaxySubscriber? subscriber = null,
IGalaxyAlarmAcknowledger? alarmAcknowledger = null,
IGalaxyAlarmFeed? alarmFeed = null,
ILogger<GalaxyDriver>? logger = null)
ILogger<GalaxyDriver>? logger = null,
ISecretResolver? secretResolver = null)
{
_driverInstanceId = !string.IsNullOrWhiteSpace(driverInstanceId)
? driverInstanceId
: throw new ArgumentException("Driver instance id required.", nameof(driverInstanceId));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? NullLogger<GalaxyDriver>.Instance;
_secretResolver = secretResolver ?? NullSecretResolver.Instance;
_hierarchySource = hierarchySource;
_dataReader = dataReader;
_dataWriter = dataWriter;
@@ -316,7 +333,7 @@ public sealed class GalaxyDriver
_driverInstanceId);
}
StartDeployWatcher();
await StartDeployWatcherAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"GalaxyDriver {InstanceId} initialized — endpoint={Endpoint} clientName={ClientName}",
_driverInstanceId, _options.Gateway.Endpoint, _options.MxAccess.ClientName);
@@ -331,7 +348,7 @@ public sealed class GalaxyDriver
/// </summary>
private async Task BuildProductionRuntimeAsync(CancellationToken cancellationToken)
{
var clientOptions = BuildClientOptions(_options.Gateway);
var clientOptions = await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false);
_ownedMxClient = MxGatewayClient.Create(clientOptions);
_ownedMxSession = new GalaxyMxSession(_options.MxAccess, _logger);
await _ownedMxSession.ConnectAsync(clientOptions, cancellationToken).ConfigureAwait(false);
@@ -386,7 +403,7 @@ public sealed class GalaxyDriver
private async Task ReopenAsync(CancellationToken cancellationToken)
{
if (_ownedMxSession is null) return;
var clientOptions = BuildClientOptions(_options.Gateway);
var clientOptions = await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false);
await _ownedMxSession.RecreateAsync(clientOptions, cancellationToken).ConfigureAwait(false);
// The recreated session invalidates every prior gw item handle; drop the writer's handle/advise
// caches so the next write re-AddItems + re-AdviseSupervisory against the fresh session.
@@ -527,32 +544,42 @@ public sealed class GalaxyDriver
}
}
private MxGatewayClientOptions BuildClientOptions(GalaxyGatewayOptions gw) => new()
private async Task<MxGatewayClientOptions> BuildClientOptionsAsync(
GalaxyGatewayOptions gw, CancellationToken cancellationToken)
{
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
// Pass the logger so the literal-arm cleartext fallback surfaces a startup
// warning rather than silently shipping the key. The resolver lives in
// Driver.Galaxy.Contracts (GalaxySecretRef) so the runtime driver and the
// AdminUI browser share one implementation.
ApiKey = GalaxySecretRef.ResolveApiKey(gw.ApiKeySecretRef, _logger),
UseTls = gw.UseTls,
CaCertificatePath = gw.CaCertificatePath,
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds),
StreamTimeout = gw.StreamTimeoutSeconds > 0 ? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds) : null,
};
// Resolve the API-key ref BEFORE the object initializer — the secret: arm is
// async and you can't await inside an initializer. Pass the logger so the
// literal-arm cleartext fallback surfaces a startup warning rather than
// silently shipping the key. The resolver lives in Driver.Galaxy.Contracts
// (GalaxySecretRef) so the runtime driver and the AdminUI browser share one
// implementation; the secret: arm resolves through the shared ISecretResolver.
var apiKey = await GalaxySecretRef
.ResolveApiKeyAsync(gw.ApiKeySecretRef, _secretResolver, _logger, cancellationToken)
.ConfigureAwait(false);
return new MxGatewayClientOptions
{
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
ApiKey = apiKey,
UseTls = gw.UseTls,
CaCertificatePath = gw.CaCertificatePath,
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds),
StreamTimeout = gw.StreamTimeoutSeconds > 0 ? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds) : null,
};
}
private void StartDeployWatcher()
private async Task StartDeployWatcherAsync(CancellationToken cancellationToken)
{
if (!_options.Repository.WatchDeployEvents) return;
if (_ownedRepositoryClient is null && _hierarchySource is null) return;
// Reuse the lazily-built repository client (DiscoverAsync constructs it on demand).
// If discovery hasn't run yet, build the client here so the watcher has a target.
// Guard with ??= so if BuildDefaultHierarchySource later runs it reuses this client
// rather than overwriting the field and leaking the first instance.
// Build under a null-check (not ??=) so if BuildDefaultHierarchySourceAsync later
// runs it reuses this client rather than overwriting the field and leaking the
// first instance — the client-options build is now async (secret: arm).
_ownedRepositoryClient ??= ZB.MOM.WW.MxGateway.Client.GalaxyRepositoryClient.Create(
BuildClientOptions(_options.Gateway));
await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false));
var source = new GatewayGalaxyDeployWatchSource(_ownedRepositoryClient);
_deployWatcher = new DeployWatcher(source, _logger);
@@ -691,7 +718,8 @@ public sealed class GalaxyDriver
// After discovery, SyncPlatformsAsync refreshes the probe watcher's membership so
// newly added $WinPlatform / $AppEngine objects start advising their ScanState attribute.
var capturingBuilder = new SecurityCapturingBuilder(builder, _securityByFullRef);
var source = _hierarchySource ??= BuildDefaultHierarchySource();
var source = _hierarchySource ??=
await BuildDefaultHierarchySourceAsync(cancellationToken).ConfigureAwait(false);
// Thread the v3 reverse resolver so the discoverer emits each authored attribute's RawPath as
// its node reference (FullName) — the security map SecurityCapturingBuilder captures is then
// keyed by RawPath, matching what WriteAsync resolves against.
@@ -1292,12 +1320,14 @@ public sealed class GalaxyDriver
/// <see cref="Dispose"/>. Tests bypass this by injecting their own source via the
/// internal ctor.
/// </summary>
private IGalaxyHierarchySource BuildDefaultHierarchySource()
private async Task<IGalaxyHierarchySource> BuildDefaultHierarchySourceAsync(CancellationToken cancellationToken)
{
// Reuse a client that StartDeployWatcher may have already created (??=) rather
// Reuse a client that StartDeployWatcherAsync may have already created (??=) rather
// than always overwriting the field and leaking the first instance. Both paths
// produce equivalent clients from the same options.
_ownedRepositoryClient ??= GalaxyRepositoryClient.Create(BuildClientOptions(_options.Gateway));
// produce equivalent clients from the same options. The client-options build is
// async now (secret: arm resolves through the shared ISecretResolver).
_ownedRepositoryClient ??= GalaxyRepositoryClient.Create(
await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false));
return new TracedGalaxyHierarchySource(
new GatewayGalaxyHierarchySource(_ownedRepositoryClient), _options.MxAccess.ClientName);
}
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
@@ -23,30 +24,45 @@ public static class GalaxyDriverFactoryExtensions
{
public const string DriverTypeName = "GalaxyMxGateway";
/// <summary>Registers the Galaxy driver factory with the given registry and optional logger factory.</summary>
/// <summary>Registers the Galaxy driver factory with the given registry, secret resolver, and optional logger factory.</summary>
/// <param name="registry">The driver factory registry to register with.</param>
/// <param name="secretResolver">
/// The shared secret resolver (from DI) used to resolve the <c>secret:</c> arm of
/// each instance's <c>Gateway.ApiKeySecretRef</c>.
/// </param>
/// <param name="loggerFactory">The optional logger factory for creating drivers.</param>
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
public static void Register(
DriverFactoryRegistry registry,
ISecretResolver secretResolver,
ILoggerFactory? loggerFactory = null)
{
ArgumentNullException.ThrowIfNull(registry);
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
ArgumentNullException.ThrowIfNull(secretResolver);
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, secretResolver));
}
/// <summary>Convenience for tests + standalone callers.</summary>
/// <summary>
/// Convenience for tests + standalone callers. Uses the <see cref="NullSecretResolver"/>
/// null-object, so the <c>secret:</c> API-key arm resolves fail-closed (absent) — callers
/// that need a working <c>secret:</c> ref must use the <see cref="Register"/> path that
/// threads the DI resolver.
/// </summary>
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
/// <param name="driverConfigJson">The driver configuration in JSON format.</param>
/// <returns>The constructed Galaxy driver instance.</returns>
public static GalaxyDriver CreateInstance(string driverInstanceId, string driverConfigJson)
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null, NullSecretResolver.Instance);
/// <summary>Creates a Galaxy driver instance from configuration JSON with optional logger factory.</summary>
/// <summary>Creates a Galaxy driver instance from configuration JSON with optional logger factory and a secret resolver.</summary>
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
/// <param name="driverConfigJson">The driver configuration in JSON format.</param>
/// <param name="loggerFactory">The optional logger factory for creating drivers.</param>
/// <param name="secretResolver">The secret resolver for the driver's <c>secret:</c> API-key arm.</param>
/// <returns>The constructed Galaxy driver instance.</returns>
public static GalaxyDriver CreateInstance(
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory, ISecretResolver secretResolver)
{
ArgumentNullException.ThrowIfNull(secretResolver);
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
@@ -88,7 +104,7 @@ public static class GalaxyDriverFactoryExtensions
RawTags = dto.RawTags ?? [],
};
return new GalaxyDriver(driverInstanceId, options, loggerFactory?.CreateLogger<GalaxyDriver>());
return new GalaxyDriver(driverInstanceId, options, secretResolver, loggerFactory?.CreateLogger<GalaxyDriver>());
}
private static readonly JsonSerializerOptions JsonOptions = new()
@@ -0,0 +1,26 @@
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
/// <summary>
/// A null-object <see cref="ISecretResolver"/> that resolves every name to <c>null</c>
/// (absent). Used as the default for the internal test ctor and the parse-only
/// <c>CreateInstance</c> path so callers that never exercise a <c>secret:</c> API-key
/// ref need not thread a real resolver. A production <c>GalaxyDriver</c> always receives
/// the DI-registered resolver via the factory; a <c>secret:</c> ref resolved against this
/// null object throws fail-closed (the secret is reported absent), which is the correct
/// behaviour for a mis-wired deployment.
/// </summary>
internal sealed class NullSecretResolver : ISecretResolver
{
/// <summary>The shared singleton instance.</summary>
public static readonly NullSecretResolver Instance = new();
private NullSecretResolver()
{
}
/// <inheritdoc />
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
Task.FromResult<string?>(null);
}
@@ -14,7 +14,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// exposes PLC data" flow. Tier A (pure managed, OPC Foundation reference SDK); universal
/// protections cover it.
/// </remarks>
public sealed class OpcUaClientDriverOptions
// A record (not a plain class) so G-2b's OpcUaClientSecretResolution can produce a
// credential-resolved copy with a `with` expression — every property keeps its init setter.
public sealed record OpcUaClientDriverOptions
{
/// <summary>
/// Remote OPC UA endpoint URL, e.g. <c>opc.tcp://plc.internal:4840</c>. Convenience
@@ -0,0 +1,26 @@
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// <summary>
/// A null-object <see cref="ISecretResolver"/> that resolves every name to <c>null</c>
/// (absent). Backs the driver's default (test/parse-only) construction path so callers that
/// never author a <c>secret:</c> credential ref need not thread a real resolver. A production
/// <c>OpcUaClientDriver</c> always receives the DI-registered resolver via the factory; a
/// <c>secret:</c> ref resolved against this null object throws fail-closed (the secret is
/// reported absent), which is the correct behaviour for a mis-wired deployment — the
/// <c>secret:</c> literal is never sent verbatim as a password.
/// </summary>
internal sealed class NullSecretResolver : ISecretResolver
{
/// <summary>The shared singleton instance.</summary>
public static readonly NullSecretResolver Instance = new();
private NullSecretResolver()
{
}
/// <inheritdoc />
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
Task.FromResult<string?>(null);
}
@@ -4,6 +4,7 @@ using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
@@ -36,15 +37,25 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// <param name="options">Driver configuration.</param>
/// <param name="driverInstanceId">Stable logical ID from the config DB.</param>
/// <param name="logger">Optional logger; defaults to NullLogger when not supplied.</param>
/// <param name="secretResolver">
/// Optional shared secret resolver used to resolve <c>secret:</c>-prefixed
/// <see cref="OpcUaClientDriverOptions.Password"/> /
/// <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/> at session-open.
/// Defaults to the <see cref="NullSecretResolver"/> null-object (which reports every
/// secret absent) for the test/parse-only path; the production factory threads the
/// DI-registered resolver. A <c>secret:</c> ref against the null-object fails closed.
/// </param>
public OpcUaClientDriver(OpcUaClientDriverOptions options, string driverInstanceId,
ILogger<OpcUaClientDriver>? logger = null)
ILogger<OpcUaClientDriver>? logger = null, ISecretResolver? secretResolver = null)
{
_options = options;
_driverInstanceId = driverInstanceId;
_logger = logger ?? NullLogger<OpcUaClientDriver>.Instance;
_secretResolver = secretResolver ?? NullSecretResolver.Instance;
}
private readonly OpcUaClientDriverOptions _options;
private readonly ISecretResolver _secretResolver;
private readonly string _driverInstanceId;
// ---- IAlarmSource state ----
@@ -159,7 +170,18 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
var appConfig = await BuildApplicationConfigurationAsync(cancellationToken).ConfigureAwait(false);
var candidates = ResolveEndpointCandidates(_options);
var identity = BuildUserIdentity(_options);
// G-2b: resolve any secret:-prefixed Password / UserCertificatePassword through the
// shared secret store ONCE, here in the async connect flow, just before the credentials
// are consumed. _options stays the raw config (with secret: refs) — only this
// connect-scoped local carries plaintext, and only for the duration of the connect.
// Resolving on every InitializeAsync (a full reconnect calls it) picks up rotations.
// Both credential consumers — the Username password below and the Certificate password
// in BuildCertificateIdentity — flow through BuildUserIdentity(resolvedOptions), so a
// single resolve covers both paths.
var resolvedOptions = await OpcUaClientSecretResolution
.ResolveSecretRefsAsync(_options, _secretResolver, cancellationToken).ConfigureAwait(false);
var identity = BuildUserIdentity(resolvedOptions);
// Failover sweep: try each endpoint in order, return the session from the first
// one that successfully connects. Per-endpoint failures are captured so the final
@@ -2,6 +2,7 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
@@ -31,22 +32,37 @@ public static class OpcUaClientDriverFactoryExtensions
Converters = { new JsonStringEnumConverter() },
};
/// <summary>Register the OpcUaClient factory with the driver registry.</summary>
/// <summary>Register the OpcUaClient factory with the driver registry, threading the shared secret resolver.</summary>
/// <param name="registry">The driver factory registry to register with.</param>
/// <param name="loggerFactory">Optional logger factory used to create per-instance loggers.</param>
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
/// <param name="secretResolver">
/// The shared secret resolver (from DI) used to resolve the <c>secret:</c> arm of each
/// instance's <c>Password</c> / <c>UserCertificatePassword</c> at session-open. Defaults to
/// the <see cref="NullSecretResolver"/> null-object for the test/standalone path — callers
/// that need a working <c>secret:</c> credential ref must thread the DI resolver.
/// </param>
public static void Register(
DriverFactoryRegistry registry,
ILoggerFactory? loggerFactory = null,
ISecretResolver? secretResolver = null)
{
ArgumentNullException.ThrowIfNull(registry);
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
var resolver = secretResolver ?? NullSecretResolver.Instance;
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, resolver));
}
/// <summary>Public for the Server-side bootstrapper + test consumers.</summary>
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
/// <param name="driverConfigJson">The JSON configuration string for the driver.</param>
/// <param name="loggerFactory">Optional logger factory for the per-instance logger.</param>
/// <param name="secretResolver">
/// Optional shared secret resolver for the driver's <c>secret:</c> credential arm; defaults
/// to the <see cref="NullSecretResolver"/> null-object (fail-closed on a <c>secret:</c> ref).
/// </param>
/// <returns>A configured <see cref="OpcUaClientDriver"/>.</returns>
public static OpcUaClientDriver CreateInstance(
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory = null)
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory = null,
ISecretResolver? secretResolver = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
@@ -55,6 +71,8 @@ public static class OpcUaClientDriverFactoryExtensions
?? throw new InvalidOperationException(
$"OpcUaClient driver config for '{driverInstanceId}' deserialised to null");
return new OpcUaClientDriver(options, driverInstanceId, loggerFactory?.CreateLogger<OpcUaClientDriver>());
return new OpcUaClientDriver(
options, driverInstanceId, loggerFactory?.CreateLogger<OpcUaClientDriver>(),
secretResolver ?? NullSecretResolver.Instance);
}
}
@@ -34,6 +34,12 @@ public sealed class OpcUaClientDriverProbe : IDriverProbe
/// <inheritdoc />
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
// G-2b note: the probe does an UNAUTHENTICATED GetEndpoints discovery preflight
// (BuildMinimalAppConfig sets no user identity) and never reads opts.Password /
// opts.UserCertificatePassword. Those credentials — including any secret: refs — are
// therefore intentionally NOT resolved here; resolving them would be dead code. Secret
// resolution happens lazily in OpcUaClientDriver's async session-open path, where the
// credentials are actually consumed.
OpcUaClientDriverOptions? opts;
try { opts = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(configJson, _opts); }
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
@@ -0,0 +1,90 @@
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// <summary>
/// Layer-B (G-2b) secret resolution for the OPC UA Client driver's credential fields. Resolves
/// <c>secret:</c>-prefixed <see cref="OpcUaClientDriverOptions.Password"/> and
/// <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/> through the shared
/// <see cref="ISecretResolver"/> (the encrypted-at-rest store) just before the async
/// session-open path consumes them — retiring the cleartext password-in-DB model for
/// production.
/// </summary>
/// <remarks>
/// Resolution is lazy (called from the connect flow, not at config deserialization) so a
/// full reconnect via <c>InitializeAsync</c> re-resolves and picks up secret rotations,
/// mirroring the Galaxy driver's re-resolve-on-reconnect behaviour. Only <c>secret:</c>-prefixed
/// values are resolved; a null/empty or non-<c>secret:</c> value passes through verbatim so the
/// literal-password back-compat path is preserved. The <c>secret:</c> arm is <b>fail-closed</b>:
/// an absent/tombstoned secret throws rather than leaving the <c>secret:</c> literal in place,
/// which would otherwise be sent verbatim to the remote server as the password.
/// </remarks>
internal static class OpcUaClientSecretResolution
{
private const string SecretPrefix = "secret:";
/// <summary>
/// Return a copy of <paramref name="options"/> with any <c>secret:</c>-prefixed
/// <see cref="OpcUaClientDriverOptions.Password"/> /
/// <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/> resolved to their
/// plaintext via <paramref name="resolver"/>. Non-secret / null / empty fields are
/// returned unchanged.
/// </summary>
/// <param name="options">The raw driver options (credential fields may carry <c>secret:</c> refs).</param>
/// <param name="resolver">The shared secret resolver used by the <c>secret:</c> arm.</param>
/// <param name="ct">Cancellation token for the async secret resolution.</param>
/// <returns>An options copy whose credential fields carry resolved plaintext.</returns>
/// <exception cref="InvalidOperationException">
/// A <c>secret:</c> ref names a secret that is absent/tombstoned in the store (fail-closed).
/// </exception>
internal static async Task<OpcUaClientDriverOptions> ResolveSecretRefsAsync(
OpcUaClientDriverOptions options, ISecretResolver resolver, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(resolver);
var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct)
.ConfigureAwait(false);
var certPassword = await ResolveFieldAsync(
options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct)
.ConfigureAwait(false);
// Only re-materialize when something actually changed — a `with` on the reference-equal
// strings is harmless, but skipping it keeps the common (no-secret) case allocation-free.
if (ReferenceEquals(password, options.Password)
&& ReferenceEquals(certPassword, options.UserCertificatePassword))
{
return options;
}
return options with { Password = password, UserCertificatePassword = certPassword };
}
/// <summary>
/// Resolve a single credential field. Null/empty or non-<c>secret:</c> values pass through
/// unchanged (the reference-equal original is returned). A <c>secret:NAME</c> value is
/// resolved through <paramref name="resolver"/> and is fail-closed when the secret is absent.
/// </summary>
/// <param name="value">The raw field value (may be a <c>secret:</c> ref).</param>
/// <param name="fieldName">The field name, used in the fail-closed exception message.</param>
/// <param name="resolver">The shared secret resolver.</param>
/// <param name="ct">Cancellation token for the async resolution.</param>
/// <returns>The resolved plaintext, or the original value when it is not a <c>secret:</c> ref.</returns>
private static async Task<string?> ResolveFieldAsync(
string? value, string fieldName, ISecretResolver resolver, CancellationToken ct)
{
if (string.IsNullOrEmpty(value)
|| !value.StartsWith(SecretPrefix, StringComparison.OrdinalIgnoreCase))
{
return value;
}
var name = value[SecretPrefix.Length..];
var resolved = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
return !string.IsNullOrEmpty(resolved)
? resolved
: throw new InvalidOperationException(
$"OpcUaClientDriverOptions.{fieldName}='{value}' resolves secret '{name}', but it is " +
"absent from the store (fail-closed).");
}
}
@@ -21,6 +21,7 @@
<ItemGroup>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client"/>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration"/>
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions"/>
</ItemGroup>
<ItemGroup>
@@ -19,7 +19,7 @@
<HeadOutlet/>
</head>
<body>
<Routes/>
<Routes AdditionalAssemblies="@(new[] { typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly })" />
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
<ThemeScripts />
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/js/monaco-init.js"></script>
@@ -20,6 +20,7 @@
<NavRailItem Href="/reservations" Text="Reservations" />
<NavRailItem Href="/certificates" Text="Certificates" />
<NavRailItem Href="/role-grants" Text="Role grants" />
<NavRailItem Href="/admin/secrets" Text="Secrets" />
</NavRailSection>
<NavRailSection Title="Scripting" Key="scripting">
<NavRailItem Href="/scripts" Text="Scripts" />
@@ -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,3 +1,4 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
@@ -10,6 +11,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
using ZB.MOM.WW.Secrets.Ui;
namespace ZB.MOM.WW.OtOpcUa.AdminUI;
@@ -28,8 +30,13 @@ public static class EndpointRouteBuilderExtensions
// Razor class library static assets (_content/ZB.MOM.WW.OtOpcUa.AdminUI/**) are
// served via the Host's app.UseStaticFiles() middleware which must run BEFORE
// UseAuthentication() — see Program.cs.
// The /admin/secrets management page lives in the external ZB.MOM.WW.Secrets.Ui RCL, so its
// routable component must be discovered at the endpoint layer (AddAdditionalAssemblies) —
// the interactive Router's AdditionalAssemblies (App.razor) alone is not enough for SSR
// endpoint discovery. Both registrations are required or /admin/secrets 404s.
app.MapRazorComponents<TApp>()
.AddInteractiveServerRenderMode();
.AddInteractiveServerRenderMode()
.AddAdditionalAssemblies(typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly);
return app;
}
@@ -43,6 +50,15 @@ public static class EndpointRouteBuilderExtensions
services.AddRazorComponents().AddInteractiveServerComponents();
services.AddOtOpcUaDriverStatusServices();
// Secrets-management UI (ZB.MOM.WW.Secrets.Ui RCL, mounted at /admin/secrets). Register its
// "secrets:manage"/"secrets:reveal" authorization policies additively onto the AuthorizationOptions
// that AddOtOpcUaAuth (called just before AddAdminUI on admin nodes) sets up — Configure<T> stacks,
// so this ADDS the two policies without disturbing FleetAdmin/DriverOperator/ConfigEditor. The
// policies' AdminRole = "Administrator" reads the same ClaimTypes.Role claim as FleetAdmin, so an
// existing Administrator satisfies them with no new group→role mapping. Registered here (the AdminUI
// composition layer that already references the RCL) rather than in the core Security lib.
services.Configure<AuthorizationOptions>(o => o.AddSecretsAuthorization());
// Browse pipeline — see docs/plans/2026-05-28-driver-browsers-design.md
services.AddSingleton<Browsing.BrowseSessionRegistry>();
services.AddHostedService<Browsing.BrowseSessionReaper>();
@@ -10,6 +10,7 @@
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client"/>
<PackageReference Include="ZB.MOM.WW.Theme"/>
<PackageReference Include="ZB.MOM.WW.Secrets.Ui"/>
</ItemGroup>
<ItemGroup>
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Host.Drivers;
@@ -48,7 +49,10 @@ public static class DriverFactoryBootstrap
// The calc driver needs the root script logger so a script failure fans out onto the
// script-logs topic; resolve it here (null on nodes without the script pipeline wired).
var scriptRoot = sp.GetService<ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger>();
Register(registry, loggerFactory, scriptRoot);
// The Galaxy driver resolves its Gateway.ApiKeySecretRef secret: arm through the
// shared ISecretResolver (registered unconditionally by AddZbSecrets on every node).
var secretResolver = sp.GetRequiredService<ISecretResolver>();
Register(registry, loggerFactory, secretResolver, scriptRoot);
return registry;
});
services.AddSingleton<IDriverFactory>(sp =>
@@ -131,15 +135,16 @@ public static class DriverFactoryBootstrap
private static void Register(
DriverFactoryRegistry registry,
ILoggerFactory? loggerFactory,
ISecretResolver secretResolver,
ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger? scriptRoot = null)
{
Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry);
Driver.AbLegacy.AbLegacyDriverFactoryExtensions.Register(registry, loggerFactory);
Driver.Calculation.CalculationDriverFactoryExtensions.Register(registry, loggerFactory, scriptRoot);
Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry);
Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, loggerFactory);
Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, secretResolver, loggerFactory);
Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory);
Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory);
Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory, secretResolver);
Driver.S7.S7DriverFactoryExtensions.Register(registry);
Driver.TwinCAT.TwinCATDriverFactoryExtensions.Register(registry);
}
@@ -37,6 +37,11 @@ using ZB.MOM.WW.OtOpcUa.Security.Ldap;
using ZB.MOM.WW.Configuration;
using ZB.MOM.WW.Telemetry.Serilog;
using ZB.MOM.WW.OtOpcUa.AdminUI.Api;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Configuration;
using ZB.MOM.WW.Secrets.DependencyInjection;
using ZB.MOM.WW.Secrets.Sqlite;
// Roles drive the entire conditional wiring below — see ZB.MOM.WW.OtOpcUa.Cluster.RoleParser.
var roles = RoleParser.Parse(Environment.GetEnvironmentVariable("OTOPCUA_ROLES"));
@@ -72,6 +77,27 @@ if (roleSuffix is not null)
builder.Configuration.AddCommandLine(args);
}
// Pre-host ${secret:} expansion: rewrites ${secret:name} tokens in the assembled configuration
// BEFORE the host is built, so every downstream binder/validator sees resolved plaintext.
// Placement matters: this runs AFTER all config providers are assembled (base appsettings, the
// role overlay, and the env-vars + command-line re-append above) but BEFORE anything READS a
// config value (AddZbSerilog's ReadFrom.Configuration, AddOtOpcUaConfigDb, and the first
// ValidateOnStart bind all follow). With no ${secret:} tokens present this is a no-op pass;
// it also migrates the secrets SQLite store on startup.
// ASP0000: DELIBERATE throwaway container — expansion must run before builder.Build(), so it
// cannot use the app's DI. Fully disposed here; shares no singletons with the host container.
#pragma warning disable ASP0000
await using (var secretsProvider = new ServiceCollection()
.AddZbSecrets(builder.Configuration, "Secrets")
.BuildServiceProvider())
#pragma warning restore ASP0000
{
await secretsProvider.GetRequiredService<SqliteSecretsStoreMigrator>().MigrateAsync(default);
var resolver = secretsProvider.GetRequiredService<ISecretResolver>();
await new SecretReferenceExpander(resolver)
.ExpandConfigurationAsync((IConfigurationRoot)builder.Configuration, default);
}
// Anchor the process working directory to the install directory (AppContext.BaseDirectory) so every
// relative runtime path resolves under the install dir rather than the service's startup CWD. A Windows
// service starts with CWD=C:\Windows\System32, which otherwise scattered the Serilog rolling-file sink
@@ -323,6 +349,9 @@ if (hasAdmin)
builder.Services.AddOtOpcUaAdminClients();
}
// Registered unconditionally: driver-role nodes resolve Layer-B DriverConfig secrets and have no auth/DP/AdminUI.
builder.Services.AddZbSecrets(builder.Configuration, "Secrets");
builder.Services.AddOtOpcUaHealth();
builder.Services.AddOtOpcUaObservability(builder.Configuration);
@@ -33,6 +33,8 @@
<PackageReference Include="ZB.MOM.WW.Telemetry" />
<PackageReference Include="ZB.MOM.WW.Telemetry.Serilog" />
<PackageReference Include="ZB.MOM.WW.Configuration" />
<PackageReference Include="ZB.MOM.WW.Secrets" />
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions" />
</ItemGroup>
<ItemGroup>
@@ -11,12 +11,21 @@
"DisableLogin": false
}
},
"Secrets": {
"SqlitePath": "otopcua-secrets.db",
"MasterKey": {
"Source": "Environment",
"EnvVarName": "ZB_SECRETS_MASTER_KEY"
},
"RunMigrationsOnStartup": true,
"ResolveCacheTtl": "00:00:30"
},
"ServerHistorian": {
"_comment": "Server-side HistoryRead backend (the ZB.MOM.WW.HistorianGateway gRPC client). Disabled => NullHistorianDataSource (historized nodes return GoodNoData). The gateway must run RuntimeDb:EventReadsEnabled=true for alarm-history ReadEvents, and the API key must carry historian:read + historian:write + historian:tags:write scopes.",
"Enabled": false,
"Endpoint": "",
"ApiKey": "",
"_ApiKeyComment": "NEVER commit a real key. Supply via the environment variable ServerHistorian__ApiKey.",
"_ApiKeyComment": "NEVER commit a real key. Supply via env var ServerHistorian__ApiKey, either as a literal or as a ${secret:otopcua/historian/api-key} token resolved fail-closed by the pre-host secrets expander.",
"UseTls": true,
"AllowUntrustedServerCertificate": false,
"CaCertificatePath": null,
@@ -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
@@ -40,8 +40,10 @@ public sealed class ServerHistorianOptions
/// <summary>
/// The peppered-HMAC API key (<c>histgw_&lt;id&gt;_&lt;secret&gt;</c>) the gateway validates
/// in the <c>Authorization: Bearer</c> header. Supply via the environment variable
/// <c>ServerHistorian__ApiKey</c> — never commit it to config. Required when
/// <see cref="Enabled"/> is <c>true</c>.
/// <c>ServerHistorian__ApiKey</c> — never commit it to config. The value may be a literal
/// key or a <c>${secret:otopcua/historian/api-key}</c> token, which the pre-host secrets
/// expander resolves fail-closed (throwing if the secret is absent) before this options
/// class binds. Required when <see cref="Enabled"/> is <c>true</c>.
/// </summary>
public string ApiKey { get; init; } = "";
@@ -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");
}
@@ -1,5 +1,6 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests;
@@ -14,7 +15,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests;
[Trait("Category", "Unit")]
public sealed class GalaxyDriverBrowserTests
{
private readonly GalaxyDriverBrowser _sut = new();
// These unit tests exercise only the pre-connect validation paths (empty endpoint,
// blank client name) that throw BEFORE the API-key secret: arm is resolved, so a
// null-returning stub resolver suffices — it is never consulted.
private readonly GalaxyDriverBrowser _sut = new(new StubSecretResolver());
/// <summary>A no-op resolver: every name resolves to null. Never consulted by these tests.</summary>
private sealed class StubSecretResolver : ISecretResolver
{
/// <inheritdoc />
public Task<string?> GetAsync(SecretName name, CancellationToken ct) => Task.FromResult<string?>(null);
}
/// <summary>The DriverType key must match the AdminUI's persisted "GalaxyMxGateway" value
/// so the factory wire-up picks the right browser implementation.</summary>
@@ -2,35 +2,48 @@ using Microsoft.Extensions.Logging;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
/// <summary>
/// Follow-up #2 — pins the three resolution forms supported by
/// <see cref="GalaxySecretRef.ResolveApiKey"/>: <c>env:NAME</c>, <c>file:PATH</c>,
/// and the literal-string fallback. A future DPAPI arm slots in here without
/// touching the call site. (The resolver was extracted from <c>GalaxyDriver</c> to
/// the shared <c>GalaxySecretRef</c> in Driver.Galaxy.Contracts so the runtime
/// driver and the AdminUI browser share one copy.)
/// Follow-up #2 + G-2a — pins the five resolution forms supported by
/// <see cref="GalaxySecretRef.ResolveApiKeyAsync"/>: <c>env:NAME</c>, <c>file:PATH</c>,
/// <c>dev:KEY</c>, <c>secret:NAME</c> (via the shared <see cref="ISecretResolver"/>), and
/// the literal-string fallback. The <c>secret:</c> arm is fail-closed — an absent secret
/// throws rather than leaking the ref string as the key. (The resolver was extracted from
/// <c>GalaxyDriver</c> to the shared <c>GalaxySecretRef</c> in Driver.Galaxy.Contracts so the
/// runtime driver and the AdminUI browser share one copy.)
/// </summary>
public sealed class GalaxyDriverApiKeyResolverTests
{
/// <summary>Name the fake resolver knows; matches the SecretName-normalized form.</summary>
private const string KnownSecretName = "galaxy/inst1/apikey";
/// <summary>The value the fake resolver returns for <see cref="KnownSecretName"/>.</summary>
private const string KnownSecretValue = "key-from-secret-store";
/// <summary>A fake resolver: returns a known value for one known name, null otherwise.</summary>
private static ISecretResolver FakeResolver() => new StubSecretResolver();
/// <summary>Verifies that a literal string is returned unchanged.</summary>
[Fact]
public void Literal_string_is_returned_unchanged()
public async Task Literal_string_is_returned_unchanged()
{
GalaxySecretRef.ResolveApiKey("plain-text-key").ShouldBe("plain-text-key");
(await GalaxySecretRef.ResolveApiKeyAsync("plain-text-key", FakeResolver()))
.ShouldBe("plain-text-key");
}
/// <summary>Verifies that env: prefix resolves to an environment variable.</summary>
[Fact]
public void Env_prefix_resolves_to_environment_variable()
public async Task Env_prefix_resolves_to_environment_variable()
{
const string name = "OTOPCUA_TEST_GALAXY_API_KEY";
Environment.SetEnvironmentVariable(name, "key-from-env");
try
{
GalaxySecretRef.ResolveApiKey($"env:{name}").ShouldBe("key-from-env");
(await GalaxySecretRef.ResolveApiKeyAsync($"env:{name}", FakeResolver()))
.ShouldBe("key-from-env");
}
finally
{
@@ -40,26 +53,27 @@ public sealed class GalaxyDriverApiKeyResolverTests
/// <summary>Verifies that unset environment variables throw with a descriptive message.</summary>
[Fact]
public void Env_prefix_unset_variable_throws_with_descriptive_message()
public async Task Env_prefix_unset_variable_throws_with_descriptive_message()
{
const string name = "OTOPCUA_TEST_GALAXY_API_KEY_UNSET";
Environment.SetEnvironmentVariable(name, null);
var ex = Should.Throw<InvalidOperationException>(() =>
GalaxySecretRef.ResolveApiKey($"env:{name}"));
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
GalaxySecretRef.ResolveApiKeyAsync($"env:{name}", FakeResolver()));
ex.Message.ShouldContain(name);
ex.Message.ShouldContain("unset");
}
/// <summary>Verifies that file: prefix resolves to trimmed file contents.</summary>
[Fact]
public void File_prefix_resolves_to_trimmed_file_contents()
public async Task File_prefix_resolves_to_trimmed_file_contents()
{
var path = Path.Combine(Path.GetTempPath(), $"galaxy-key-{Guid.NewGuid():N}.txt");
File.WriteAllText(path, " key-from-file \n");
try
{
GalaxySecretRef.ResolveApiKey($"file:{path}").ShouldBe("key-from-file");
(await GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver()))
.ShouldBe("key-from-file");
}
finally
{
@@ -69,26 +83,60 @@ public sealed class GalaxyDriverApiKeyResolverTests
/// <summary>Verifies that file: prefix with missing path throws.</summary>
[Fact]
public void File_prefix_missing_path_throws()
public async Task File_prefix_missing_path_throws()
{
var path = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.txt");
var ex = Should.Throw<InvalidOperationException>(() =>
GalaxySecretRef.ResolveApiKey($"file:{path}"));
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver()));
ex.Message.ShouldContain(path);
ex.Message.ShouldContain("doesn't exist");
}
// ===== G-2a: secret: arm — resolves through the shared ISecretResolver, fail-closed =====
/// <summary>Verifies that secret: prefix resolves through the ISecretResolver.</summary>
[Fact]
public async Task Secret_prefix_resolves_through_secret_resolver()
{
(await GalaxySecretRef.ResolveApiKeyAsync($"secret:{KnownSecretName}", FakeResolver()))
.ShouldBe(KnownSecretValue);
}
/// <summary>Verifies that an absent secret throws (fail-closed) and does NOT leak the ref string.</summary>
[Fact]
public async Task Secret_prefix_absent_secret_throws_fail_closed()
{
const string missingRef = "secret:galaxy/missing/apikey";
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
GalaxySecretRef.ResolveApiKeyAsync(missingRef, FakeResolver()));
// Fail-closed: must throw, must reference the secret + "absent"/"fail-closed", and
// must NOT return the ref string as the key.
ex.Message.ShouldContain("galaxy/missing/apikey");
ex.Message.ShouldContain("absent");
}
/// <summary>Verifies that the secret: arm does not consult the logger's literal warning.</summary>
[Fact]
public async Task Secret_prefix_does_not_emit_literal_warning()
{
var logger = new CaptureLogger();
await GalaxySecretRef.ResolveApiKeyAsync($"secret:{KnownSecretName}", FakeResolver(), logger);
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
}
// ===== Driver.Galaxy-010 regression: literal arm warns + dev: prefix path =====
/// <summary>Verifies that literal strings emit a warning when a logger is supplied.</summary>
[Fact]
public void Literal_string_emits_warning_when_logger_supplied()
public async Task Literal_string_emits_warning_when_logger_supplied()
{
// A literal API key on a production deployment means the cleartext key sits
// in the DriverConfig JSON. The resolver must surface a warning so an
// operator who committed one by accident sees it at startup.
var logger = new CaptureLogger();
var key = GalaxySecretRef.ResolveApiKey("plain-text-key", logger);
var key = await GalaxySecretRef.ResolveApiKeyAsync("plain-text-key", FakeResolver(), logger);
key.ShouldBe("plain-text-key");
logger.Entries.ShouldContain(e =>
@@ -97,13 +145,13 @@ public sealed class GalaxyDriverApiKeyResolverTests
/// <summary>Verifies that dev: prefix returns literal text without emitting warnings.</summary>
[Fact]
public void Dev_prefix_returns_literal_without_warning()
public async Task Dev_prefix_returns_literal_without_warning()
{
// An explicit dev: prefix signals the operator knowingly opted into a literal
// key (dev / parity rig). The resolver must accept it AND suppress the
// warning so production logs aren't polluted on a deliberate dev choice.
var logger = new CaptureLogger();
var key = GalaxySecretRef.ResolveApiKey("dev:plain-text-key", logger);
var key = await GalaxySecretRef.ResolveApiKeyAsync("dev:plain-text-key", FakeResolver(), logger);
key.ShouldBe("plain-text-key");
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
@@ -111,14 +159,14 @@ public sealed class GalaxyDriverApiKeyResolverTests
/// <summary>Verifies that env: prefix does not emit literal string warnings.</summary>
[Fact]
public void Env_prefix_does_not_emit_literal_warning()
public async Task Env_prefix_does_not_emit_literal_warning()
{
const string name = "OTOPCUA_TEST_GALAXY_API_KEY_NOWARN";
Environment.SetEnvironmentVariable(name, "v");
try
{
var logger = new CaptureLogger();
GalaxySecretRef.ResolveApiKey($"env:{name}", logger);
await GalaxySecretRef.ResolveApiKeyAsync($"env:{name}", FakeResolver(), logger);
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
}
finally
@@ -127,6 +175,14 @@ public sealed class GalaxyDriverApiKeyResolverTests
}
}
/// <summary>Verifies that a null resolver is rejected (ArgumentNullException).</summary>
[Fact]
public async Task Null_resolver_throws()
{
await Should.ThrowAsync<ArgumentNullException>(() =>
GalaxySecretRef.ResolveApiKeyAsync("plain-text-key", resolver: null!));
}
/// <summary>A test logger that captures log entries for verification.</summary>
private sealed class CaptureLogger : ILogger
{
@@ -144,16 +200,27 @@ public sealed class GalaxyDriverApiKeyResolverTests
=> Entries.Add((logLevel, formatter(state, exception)));
}
/// <summary>A fake ISecretResolver returning one known value; null for every other name.</summary>
private sealed class StubSecretResolver : ISecretResolver
{
/// <inheritdoc />
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
Task.FromResult<string?>(
string.Equals(name.Value, KnownSecretName, StringComparison.Ordinal)
? KnownSecretValue
: null);
}
/// <summary>Verifies that file: prefix with empty file throws.</summary>
[Fact]
public void File_prefix_empty_file_throws()
public async Task File_prefix_empty_file_throws()
{
var path = Path.Combine(Path.GetTempPath(), $"galaxy-key-empty-{Guid.NewGuid():N}.txt");
File.WriteAllText(path, " \n ");
try
{
var ex = Should.Throw<InvalidOperationException>(() =>
GalaxySecretRef.ResolveApiKey($"file:{path}"));
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver()));
ex.Message.ShouldContain("empty");
}
finally
@@ -143,7 +143,7 @@ public sealed class GalaxyDriverFactoryTests
public void Register_AddsFactoryToRegistry()
{
var registry = new DriverFactoryRegistry();
GalaxyDriverFactoryExtensions.Register(registry);
GalaxyDriverFactoryExtensions.Register(registry, NullSecretResolver.Instance);
registry.RegisteredTypes.ShouldContain(GalaxyDriverFactoryExtensions.DriverTypeName);
@@ -78,7 +78,7 @@ public sealed class GalaxyDriverReadTests
// _dataReader and _subscriber are both null. The follow-up read path can't
// synthesise a Read without one, so it surfaces a NotSupportedException
// pointing at the misuse rather than NullRef'ing inside the pump path.
var driver = new GalaxyDriver("g", Opts());
var driver = new GalaxyDriver("g", Opts(), hierarchySource: null);
var ex = await Should.ThrowAsync<NotSupportedException>(() =>
driver.ReadAsync(["x"], CancellationToken.None));
@@ -242,7 +242,7 @@ public sealed class GalaxyDriverSubscribeTests
[Fact]
public async Task SubscribeAsync_NoSubscriber_Throws()
{
using var driver = new GalaxyDriver("g", Opts());
using var driver = new GalaxyDriver("g", Opts(), hierarchySource: null);
var ex = await Should.ThrowAsync<NotSupportedException>(() =>
driver.SubscribeAsync(["x"], TimeSpan.FromSeconds(1), CancellationToken.None));
ex.Message.ShouldContain("GalaxyDriver.SubscribeAsync requires");
@@ -192,7 +192,7 @@ public sealed class GalaxyDriverWriteTests
[Fact]
public async Task WriteAsync_NoWriter_Throws()
{
var driver = new GalaxyDriver("g", Opts());
var driver = new GalaxyDriver("g", Opts(), hierarchySource: null);
var ex = await Should.ThrowAsync<NotSupportedException>(() =>
driver.WriteAsync([new WriteRequest("x", 1)], CancellationToken.None));
@@ -0,0 +1,95 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// G-2b — pins <see cref="OpcUaClientSecretResolution.ResolveSecretRefsAsync"/>, the Layer-B
/// arm that resolves <c>secret:</c>-prefixed <see cref="OpcUaClientDriverOptions.Password"/>
/// and <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/> through the shared
/// <see cref="ISecretResolver"/> just before the async session-open consumes them. The
/// <c>secret:</c> arm is fail-closed — an absent secret throws rather than leaving the
/// <c>secret:</c> literal in place (which would be sent verbatim as the password).
/// Non-secret literals and null/empty pass through unchanged.
/// </summary>
public sealed class OpcUaClientSecretResolutionTests
{
private const string KnownPwName = "opcua/inst/password";
private const string KnownPwValue = "resolved-password-plaintext";
private const string KnownCertPwName = "opcua/inst/cert-pw";
private const string KnownCertPwValue = "resolved-cert-pw-plaintext";
/// <summary>A fake resolver: returns known values for two known names, null otherwise.</summary>
private static ISecretResolver FakeResolver() => new StubSecretResolver();
/// <summary>(a) A <c>secret:</c> Password resolves to the store's plaintext.</summary>
[Fact]
public async Task Secret_password_resolves_to_store_plaintext()
{
var options = new OpcUaClientDriverOptions { Password = $"secret:{KnownPwName}" };
var resolved = await OpcUaClientSecretResolution.ResolveSecretRefsAsync(
options, FakeResolver(), CancellationToken.None);
resolved.Password.ShouldBe(KnownPwValue);
}
/// <summary>(b) A <c>secret:</c> UserCertificatePassword resolves to the store's plaintext.</summary>
[Fact]
public async Task Secret_cert_password_resolves_to_store_plaintext()
{
var options = new OpcUaClientDriverOptions { UserCertificatePassword = $"secret:{KnownCertPwName}" };
var resolved = await OpcUaClientSecretResolution.ResolveSecretRefsAsync(
options, FakeResolver(), CancellationToken.None);
resolved.UserCertificatePassword.ShouldBe(KnownCertPwValue);
}
/// <summary>(c) A non-secret literal Password and a null field pass through unchanged.</summary>
[Fact]
public async Task Literal_and_null_pass_through_unchanged()
{
var options = new OpcUaClientDriverOptions
{
Password = "plainpw",
UserCertificatePassword = null,
};
var resolved = await OpcUaClientSecretResolution.ResolveSecretRefsAsync(
options, FakeResolver(), CancellationToken.None);
resolved.Password.ShouldBe("plainpw");
resolved.UserCertificatePassword.ShouldBeNull();
}
/// <summary>(d) An unknown <c>secret:</c> ref (resolver returns null) is fail-closed — it throws
/// and never leaves the <c>secret:</c> literal in place.</summary>
[Fact]
public async Task Unknown_secret_ref_is_fail_closed()
{
var options = new OpcUaClientDriverOptions { Password = "secret:opcua/inst/absent" };
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
OpcUaClientSecretResolution.ResolveSecretRefsAsync(
options, FakeResolver(), CancellationToken.None));
ex.Message.ShouldContain("Password");
ex.Message.ShouldContain("opcua/inst/absent");
}
/// <summary>A fake resolver: returns known values for two known names, null otherwise.</summary>
private sealed class StubSecretResolver : ISecretResolver
{
/// <inheritdoc />
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
Task.FromResult<string?>(name.Value switch
{
KnownPwName => KnownPwValue,
KnownCertPwName => KnownCertPwValue,
_ => null,
});
}
}
@@ -0,0 +1,110 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Configuration;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Secrets-adoption (G-2c) regression guard: the AdminUI driver-config editor persists <c>DriverConfig</c>
/// as an OPAQUE JSON string through <see cref="RawTreeService"/> (see <c>UpdateDriverAsync</c> /
/// <c>CreateDriverAsync</c> — <c>entity.DriverConfig = driverConfigJson</c> verbatim, and
/// <c>LoadDriverForEditAsync</c> reads it back verbatim). A <c>secret:</c>/<c>env:</c>/<c>file:</c>
/// reference stored in a driver secret field (OpcUaClient <c>Password</c>/<c>UserCertificatePassword</c>,
/// Galaxy <c>Gateway.ApiKeySecretRef</c>) MUST survive save→reload as the literal ref string — the save
/// path must never resolve-then-resave cleartext (which would re-leak the secret into the config store and
/// defeat G-2). Secret resolution belongs ONLY at driver-instantiation / Test-connect time (Tasks 7-8),
/// never on the AdminUI persistence path — and indeed <see cref="RawTreeService"/> takes no secret resolver
/// (its only dependency is the DbContext factory), so resolution is structurally impossible here.
/// <para>
/// Reuses the shared <see cref="RawTreeTestDb"/> InMemory fixture, matching the WP3 driver-config tests.
/// </para>
/// </summary>
[Trait("Category", "Unit")]
public sealed class RawTreeServiceSecretRefRoundTripTests
{
private static (RawTreeService Service, string DbName) Seeded()
{
var name = RawTreeTestDb.SeedFresh();
return (new RawTreeService(RawTreeTestDb.Factory(name)), name);
}
private static byte[] RowVersionOf(string dbName, string driverId)
{
using var db = RawTreeTestDb.CreateNamed(dbName);
return db.DriverInstances.Single(d => d.DriverInstanceId == driverId).RowVersion;
}
[Fact]
public async Task UpdateDriver_preserves_OpcUaClient_secret_refs_verbatim_on_save_and_reload()
{
var (service, dbName) = Seeded();
var rv = RowVersionOf(dbName, RawTreeTestDb.FolderedDriverId);
// The editor emits the raw ref strings the user typed — they must NOT be resolved on save.
const string config =
"{\"Password\":\"secret:opcua/x/pw\",\"UserCertificatePassword\":\"secret:opcua/x/certpw\"}";
var result = await service.UpdateDriverAsync(
RawTreeTestDb.FolderedDriverId, "modbus-1", config, null, rv);
result.Ok.ShouldBeTrue();
// Reload through the same seam the modal uses.
var reloaded = await service.LoadDriverForEditAsync(RawTreeTestDb.FolderedDriverId);
reloaded.ShouldNotBeNull();
// Byte-identical: nothing on the save path rewrote the string.
reloaded!.DriverConfig.ShouldBe(config);
// The literal refs survived (not resolved to cleartext, not stripped).
reloaded.DriverConfig.ShouldContain("secret:opcua/x/pw");
reloaded.DriverConfig.ShouldContain("secret:opcua/x/certpw");
// And the raw column agrees with the projection.
using var db = RawTreeTestDb.CreateNamed(dbName);
db.DriverInstances.Single(d => d.DriverInstanceId == RawTreeTestDb.FolderedDriverId)
.DriverConfig.ShouldBe(config);
}
[Fact]
public async Task CreateDriver_preserves_Galaxy_ApiKeySecretRef_verbatim_on_save_and_reload()
{
var (service, dbName) = Seeded();
const string config = "{\"Gateway\":{\"ApiKeySecretRef\":\"secret:galaxy/x/apikey\"}}";
var created = await service.CreateDriverAsync(
RawTreeTestDb.ClusterId, RawTreeTestDb.RootFolderId, "galaxy-1", "Galaxy", config);
created.Ok.ShouldBeTrue();
created.CreatedId.ShouldNotBeNull();
var reloaded = await service.LoadDriverForEditAsync(created.CreatedId!);
reloaded.ShouldNotBeNull();
reloaded!.DriverConfig.ShouldBe(config);
reloaded.DriverConfig.ShouldContain("secret:galaxy/x/apikey");
using var db = RawTreeTestDb.CreateNamed(dbName);
db.DriverInstances.Single(d => d.DriverInstanceId == created.CreatedId)
.DriverConfig.ShouldBe(config);
}
[Fact]
public async Task UpdateDriver_preserves_env_and_file_ref_forms_verbatim()
{
var (service, dbName) = Seeded();
var rv = RowVersionOf(dbName, RawTreeTestDb.FolderedDriverId);
const string config =
"{\"Password\":\"env:OPCUA_PW\",\"UserCertificatePassword\":\"file:/run/secrets/certpw\"}";
var result = await service.UpdateDriverAsync(
RawTreeTestDb.FolderedDriverId, "modbus-1", config, null, rv);
result.Ok.ShouldBeTrue();
var reloaded = await service.LoadDriverForEditAsync(RawTreeTestDb.FolderedDriverId);
reloaded!.DriverConfig.ShouldBe(config);
reloaded.DriverConfig.ShouldContain("env:OPCUA_PW");
reloaded.DriverConfig.ShouldContain("file:/run/secrets/certpw");
}
}
@@ -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);
@@ -4,6 +4,7 @@ using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
using ZB.MOM.WW.OtOpcUa.Host.Drivers;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
@@ -24,6 +25,9 @@ public sealed class ResilienceInvokerFactoryRegistrationTests
{
var services = new ServiceCollection();
services.AddLogging();
// The driver-factory registration resolves ISecretResolver (Galaxy/OpcUaClient secret: refs);
// the real host always has it via AddZbSecrets (registered unconditionally). Mirror that here.
services.AddSingleton<ISecretResolver>(new StubSecretResolver());
services.AddOtOpcUaDriverFactories();
using var sp = services.BuildServiceProvider();
@@ -38,6 +42,9 @@ public sealed class ResilienceInvokerFactoryRegistrationTests
{
var services = new ServiceCollection();
services.AddLogging();
// The driver-factory registration resolves ISecretResolver (Galaxy/OpcUaClient secret: refs);
// the real host always has it via AddZbSecrets (registered unconditionally). Mirror that here.
services.AddSingleton<ISecretResolver>(new StubSecretResolver());
services.AddOtOpcUaDriverFactories();
using var sp = services.BuildServiceProvider();
@@ -54,6 +61,9 @@ public sealed class ResilienceInvokerFactoryRegistrationTests
{
var services = new ServiceCollection();
services.AddLogging();
// The driver-factory registration resolves ISecretResolver (Galaxy/OpcUaClient secret: refs);
// the real host always has it via AddZbSecrets (registered unconditionally). Mirror that here.
services.AddSingleton<ISecretResolver>(new StubSecretResolver());
services.AddOtOpcUaDriverFactories();
using var sp = services.BuildServiceProvider();
@@ -65,4 +75,12 @@ public sealed class ResilienceInvokerFactoryRegistrationTests
sp.GetRequiredService<DriverResiliencePipelineBuilder>()
.ShouldBeSameAs(sp.GetRequiredService<DriverResiliencePipelineBuilder>(), "builder must be a singleton");
}
// Minimal ISecretResolver so AddOtOpcUaDriverFactories' registration (which resolves it for
// Galaxy/OpcUaClient secret: refs) can be built. These tests exercise no secret: refs, so
// returning null for every name is sufficient — the real resolver comes from AddZbSecrets.
private sealed class StubSecretResolver : ISecretResolver
{
public Task<string?> GetAsync(SecretName name, CancellationToken ct) => Task.FromResult<string?>(null);
}
}
@@ -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,330 @@
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>
/// Issue #473 — the over-the-wire proof that a live condition event carries the mandatory
/// <c>BaseEventType</c> identity fields <c>EventType</c> / <c>SourceNode</c> / <c>SourceName</c>. All three
/// previously arrived <b>null</b> on every condition event: <c>MaterialiseAlarmCondition</c> never assigned
/// them, and nothing downstream synthesises them (<c>ReportEvent</c> / <c>InstanceStateSnapshot</c> copy the
/// condition's children verbatim), so a conforming client could not attribute an alarm to its source.
///
/// <para>This is the WIRE-level guard the node-level
/// <c>NodeManagerAlarmSourceFieldsTests</c> cannot give: it proves the values survive the snapshot +
/// serialization all the way into a real client's <c>EventFieldList</c>. The select clause is deliberately
/// the exact one a real consumer uses — ScadaBridge's Data Connection Layer selects
/// <c>[EventType, SourceNode, SourceName, Time, Message, Severity]</c> — so this test fails the way that
/// client failed.</para>
///
/// <para>Boots the real <see cref="OtOpcUaSdkServer"/> and drives the production
/// <see cref="SdkAddressSpaceSink"/>, mirroring <c>NativeAlarmMultiNotifierEventDeliveryTests</c>. Heavy
/// in-process server+client integration — runs in the serial integration pass.</para>
/// </summary>
public sealed class NativeAlarmEventIdentityFieldDeliveryTests
{
private const string ServerUri = "urn:OtOpcUa.AlarmEventIdentityFields";
private const string ConditionClassServerUri = "urn:OtOpcUa.AlarmEventConditionClassFields";
private const string RawDeviceFolder = "pymodbus/plc";
private const string RawAlarmPath = "pymodbus/plc/HR200";
private const string LeafName = "HR200";
private const string Equip1 = "EQ-filling-line1-station1";
private const string AlarmMessage = "HR200 over limit (identity-field test)";
// Field indices in the select clause built by BuildEventFilter — ScadaBridge's exact clause.
private const int EventTypeIndex = 0;
private const int SourceNodeIndex = 1;
private const int SourceNameIndex = 2;
private const int TimeIndex = 3;
private const int MessageIndex = 4;
private const int SeverityIndex = 5;
// Field indices in BuildConditionClassEventFilter's clause (#475). A SEPARATE clause on purpose: the
// ScadaBridge one above is mirrored field-for-field from the real consumer and its indices are load-bearing,
// so appending to it would silently shift them. Message is re-selected here only as the collector's filter key.
private const int ConditionClassIdIndex = 0;
private const int ConditionClassNameIndex = 1;
private const int ConditionClassMessageIndex = 2;
/// <summary>A live native condition event delivers a populated EventType / SourceNode / SourceName to a
/// Server-object subscriber using the standard BaseEventType select clause.</summary>
[Fact]
public async Task Condition_event_carries_populated_EventType_SourceNode_and_SourceName_on_the_wire()
{
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-alarm-identity-{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!);
WireCondition(sink);
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
// Resolve the RAW namespace index CLIENT-side (from the server's advertised NamespaceArray) so the
// expected SourceNode is built exactly as a real client would resolve it.
var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
rawNs.ShouldBeGreaterThan((ushort)0);
var collector = new EventCollector(MessageIndex);
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
subscription.FastEventCallback = collector.OnEvents;
session.AddSubscription(subscription);
await subscription.CreateAsync(ct);
// The collector filters by our unique Message, so the item's ClientHandle is not needed here
// (unlike the sibling multi-notifier test, which tallies delivery per monitored item).
AddEventItem(subscription, ObjectIds.Server, BuildEventFilter());
await subscription.ApplyChangesAsync(ct);
sink.WriteAlarmCondition(RawAlarmPath, ActiveSnapshot(), DateTime.UtcNow, AddressSpaceRealm.Raw);
await WaitUntilAsync(() => collector.Fields.Count >= 1, TimeSpan.FromSeconds(5));
var fields = collector.Fields.ShouldHaveSingleItem();
// --- the three fields this issue is about ---
// EventType: the concrete condition type, readable as a FIELD (not just via an OfType where-clause).
fields[EventTypeIndex].Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType,
"EventType must carry the concrete condition type, not null");
// SourceNode: the condition's own NodeId in the RAW namespace == ConditionId.
fields[SourceNodeIndex].Value.ShouldBe(new NodeId(RawAlarmPath, rawNs),
"SourceNode must identify the source condition node, not null");
// SourceName: the full RawPath — unique across devices, matching SourceNode/ConditionId. The leaf
// name (HR200) is deliberately NOT used here: it collides across PLCs and is carried by ConditionName.
fields[SourceNameIndex].Value.ShouldBe(RawAlarmPath,
"SourceName must carry the unique RawPath, not null");
fields[SourceNameIndex].Value.ShouldNotBe(LeafName,
"SourceName is deliberately the unique RawPath, not the ambiguous leaf name");
// The rest of the standard envelope still arrives intact (guards against a regression that
// populated identity at the cost of the existing fields).
fields[TimeIndex].Value.ShouldBeOfType<DateTime>();
((LocalizedText)fields[MessageIndex].Value).Text.ShouldBe(AlarmMessage);
fields[SeverityIndex].Value.ShouldBe((ushort)700);
await subscription.DeleteAsync(true, ct);
}
finally
{
SafeDelete(pkiRoot + "-srv");
}
}
/// <summary>Issue #475 — a live condition event delivers a populated ConditionClassId / ConditionClassName.
/// Both previously arrived unset (NodeId.Null / empty text) via the same mechanism as the #473 fields, so an
/// HMI bucketing alarms by condition class dropped every OtOpcUa alarm into an unclassified bin.</summary>
[Fact]
public async Task Condition_event_carries_populated_ConditionClass_fields_on_the_wire()
{
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-alarm-condclass-{Guid.NewGuid():N}");
var port = AllocateFreePort();
var ct = TestContext.Current.CancellationToken;
try
{
var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ConditionClassServerUri, ct);
await using var _ = host;
var sink = new SdkAddressSpaceSink(server.NodeManager!);
WireCondition(sink);
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
var collector = new EventCollector(ConditionClassMessageIndex);
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
subscription.FastEventCallback = collector.OnEvents;
session.AddSubscription(subscription);
await subscription.CreateAsync(ct);
AddEventItem(subscription, ObjectIds.Server, BuildConditionClassEventFilter());
await subscription.ApplyChangesAsync(ct);
sink.WriteAlarmCondition(RawAlarmPath, ActiveSnapshot(), DateTime.UtcNow, AddressSpaceRealm.Raw);
await WaitUntilAsync(() => collector.Fields.Count >= 1, TimeSpan.FromSeconds(5));
var fields = collector.Fields.ShouldHaveSingleItem();
// ConditionClassId — a resolvable class NodeId, NOT NodeId.Null. We model no condition classes, so
// Part 9's "no class modelled" value (BaseConditionClassType) is the conformant report.
fields[ConditionClassIdIndex].Value.ShouldBe(ObjectTypeIds.BaseConditionClassType,
"ConditionClassId must carry a resolvable condition class, not null");
// ConditionClassName — the matching human-readable name, NOT empty text.
var className = fields[ConditionClassNameIndex].Value.ShouldBeOfType<LocalizedText>();
className.Text.ShouldBe("BaseConditionClass",
"ConditionClassName must name the reported condition class, not be empty");
await subscription.DeleteAsync(true, ct);
}
finally
{
SafeDelete(pkiRoot + "-srv");
}
}
/// <summary>Materialise the raw device folder + the native condition at its RawPath, plus one referencing
/// equipment folder wired as a notifier (the production topology).</summary>
private static void WireCondition(SdkAddressSpaceSink sink)
{
sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "plc", realm: AddressSpaceRealm.Raw);
sink.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, LeafName, "OffNormalAlarm", 700, AddressSpaceRealm.Raw, isNative: true);
sink.EnsureFolder(Equip1, parentNodeId: null, displayName: "station1", realm: AddressSpaceRealm.Uns);
sink.WireAlarmNotifiers(RawAlarmPath, AddressSpaceRealm.Raw, new[] { Equip1 }, 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, EventFilter filter)
{
var item = new MonitoredItem(subscription.DefaultItem)
{
StartNodeId = source,
AttributeId = Attributes.EventNotifier,
Filter = filter,
QueueSize = 100,
SamplingInterval = 0,
};
subscription.AddItem(item);
return item;
}
/// <summary>ScadaBridge's exact select clause: [EventType, SourceNode, SourceName, Time, Message, Severity].</summary>
private static EventFilter BuildEventFilter()
{
var filter = new EventFilter();
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.EventType);
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.SourceNode);
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.SourceName);
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Time);
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message);
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Severity);
return filter;
}
/// <summary>#475's clause: [ConditionClassId, ConditionClassName, Message]. The two class fields are declared on
/// <c>ConditionType</c>, not BaseEventType, so they must be selected against that type or the server returns no
/// value for them regardless of the fix. Message rides along solely as the collector's filter key.</summary>
private static EventFilter BuildConditionClassEventFilter()
{
var filter = new EventFilter();
filter.AddSelectClause(ObjectTypes.ConditionType, BrowseNames.ConditionClassId);
filter.AddSelectClause(ObjectTypes.ConditionType, BrowseNames.ConditionClassName);
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message);
return filter;
}
/// <summary>Captures the delivered event field lists, filtered to our unique alarm Message so unrelated
/// server events / refresh brackets never count.</summary>
/// <param name="messageIndex">Position of the Message field in the select clause this collector is paired
/// with — the two clauses here place it differently, so it cannot be a shared constant.</param>
private sealed class EventCollector(int messageIndex)
{
private readonly object _gate = new();
private readonly List<VariantCollection> _fields = new();
public IReadOnlyList<VariantCollection> Fields
{
get { lock (_gate) return _fields.ToList(); }
}
public void OnEvents(Subscription subscription, EventNotificationList notification, IList<string> stringTable)
{
lock (_gate)
{
foreach (var e in notification.Events)
{
if (e.EventFields.Count > messageIndex &&
e.EventFields[messageIndex].Value is LocalizedText lt &&
lt.Text == AlarmMessage)
{
_fields.Add(e.EventFields);
}
}
}
}
}
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.AlarmEventIdentityFieldsClient",
ApplicationUri = $"urn:OtOpcUa.AlarmEventIdentityFieldsClient.{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: "AlarmEventIdentityFieldDeliveryTests", 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 */ }
}
}
}
@@ -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();
@@ -0,0 +1,240 @@
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// Issue #473 — every materialised Part 9 condition must carry the mandatory <c>BaseEventType</c>
/// identity fields <c>EventType</c> / <c>SourceNode</c> / <c>SourceName</c>. The SDK does NOT synthesise
/// them on this path: <see cref="NodeState.Create"/> initialises from the type's embedded definition,
/// which declares all three as mandatory children with NO default value, and the auto-filling
/// <see cref="BaseEventState.Initialize(ISystemContext, NodeState, EventSeverity, LocalizedText)"/>
/// overload (which would set SourceNode/SourceName from a source node) is only used for transient
/// events. <c>ReportEvent</c> and <c>InstanceStateSnapshot</c> copy the children verbatim and synthesise
/// nothing — so a field left null at materialise arrives null on the wire on EVERY condition event.
/// <para>
/// The contract locked in here (see <c>docs/AlarmTracking.md</c>): the condition node IS the source
/// node — a native-alarm raw tag materialises ONLY a condition (no separate value variable), so
/// <c>SourceNode</c> self-references the condition's own NodeId and <c>SourceName</c> carries the
/// same identifying id string (<c>alarmNodeId</c>: the RawPath for native, the ScriptedAlarmId for
/// scripted), matching <c>ConditionId</c>. The leaf/display name stays on <c>ConditionName</c>, so
/// no information is lost by SourceName carrying the unique id rather than the ambiguous leaf.
/// </para>
/// <para>
/// Issue #475 — the same mechanism leaves the mandatory <c>ConditionType</c> classification fields
/// <c>ConditionClassId</c> / <c>ConditionClassName</c> unset. The contract locked in here: a server that
/// does not model condition classes must still report <c>BaseConditionClassType</c> (Part 9's
/// "no class modelled" value) rather than null. See <c>docs/AlarmTracking.md</c>.
/// </para>
/// </summary>
public sealed class NodeManagerAlarmSourceFieldsTests : IDisposable
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private const string RawDeviceFolder = "Plant/Modbus/dev1";
private const string RawAlarmPath = "Plant/Modbus/dev1/HR200";
private readonly string _pkiRoot = Path.Combine(
Path.GetTempPath(),
$"otopcua-alarm-src-fields-{Guid.NewGuid():N}");
/// <summary>A NATIVE condition (Raw realm, ConditionId = RawPath) carries all three identity fields:
/// EventType = its type definition, SourceNode = its own NodeId, SourceName = the RawPath. The leaf name
/// remains on ConditionName so the short name is still available to clients.</summary>
[Trait("Category", "Unit")]
[Fact]
public async Task Native_condition_carries_EventType_SourceNode_and_SourceName()
{
await using var host = await BootAsync();
var nm = host.Nm;
var rawNs = nm.NamespaceIndexForRealm(AddressSpaceRealm.Raw);
nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
nm.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, "HR200", "OffNormalAlarm", 700,
realm: AddressSpaceRealm.Raw, isNative: true);
var condition = nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw);
condition.ShouldNotBeNull();
// EventType — the concrete condition type, so a client reading the FIELD (not just an OfType
// where-clause) can resolve the type.
condition.EventType.ShouldNotBeNull();
condition.EventType.Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType);
// SourceNode — the condition's own NodeId (it IS the source: no separate value variable exists
// for an alarm-bearing raw tag). Equal to ConditionId.
condition.SourceNode.ShouldNotBeNull();
condition.SourceNode.Value.ShouldBe(new NodeId(RawAlarmPath, rawNs));
// SourceName — the RawPath: unique across devices, matching ConditionId/SourceNode.
condition.SourceName.ShouldNotBeNull();
condition.SourceName.Value.ShouldBe(RawAlarmPath);
// The leaf name is NOT lost — it stays on ConditionName.
condition.ConditionName!.Value.ShouldBe("HR200");
}
/// <summary>A SCRIPTED condition (UNS realm, ConditionId = ScriptedAlarmId) follows the SAME uniform rule:
/// SourceNode = its own NodeId, SourceName = the ScriptedAlarmId. Scripted and native conditions share the
/// materialise path, so neither may regress independently.</summary>
[Trait("Category", "Unit")]
[Fact]
public async Task Scripted_condition_carries_EventType_SourceNode_and_SourceName()
{
await using var host = await BootAsync();
var nm = host.Nm;
var unsNs = nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns);
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Station 1", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("tank-overflow", "eq-1", "Tank Overflow", "OffNormalAlarm", 700,
realm: AddressSpaceRealm.Uns, isNative: false);
var condition = nm.TryGetAlarmCondition("tank-overflow", AddressSpaceRealm.Uns);
condition.ShouldNotBeNull();
condition.EventType!.Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType);
condition.SourceNode!.Value.ShouldBe(new NodeId("tank-overflow", unsNs));
condition.SourceName!.Value.ShouldBe("tank-overflow");
// The human-readable name stays on ConditionName.
condition.ConditionName!.Value.ShouldBe("Tank Overflow");
}
/// <summary>EventType tracks the ACTUAL materialised type, not a hardcoded constant: an unknown alarm type
/// falls back to the base <see cref="AlarmConditionState"/> and its EventType must report that base type
/// rather than a subtype the node does not have.</summary>
[Trait("Category", "Unit")]
[Fact]
public async Task EventType_reflects_the_fallback_base_type_for_an_unknown_alarm_type()
{
await using var host = await BootAsync();
var nm = host.Nm;
nm.EnsureFolder("eq-2", parentNodeId: null, displayName: "Station 2", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-x", "eq-2", "Generic", "LimitAlarm", 500,
realm: AddressSpaceRealm.Uns, isNative: false);
var condition = nm.TryGetAlarmCondition("alm-x", AddressSpaceRealm.Uns);
condition.ShouldNotBeNull();
condition.GetType().ShouldBe(typeof(AlarmConditionState)); // base-type fallback
condition.EventType!.Value.ShouldBe(ObjectTypeIds.AlarmConditionType);
}
/// <summary>The identity fields survive a re-materialise that DROPS and re-creates the node (the native↔scripted
/// kind-swap path), so a redeploy can never leave a condition emitting null identity.</summary>
[Trait("Category", "Unit")]
[Fact]
public async Task Identity_fields_survive_a_kind_swap_rematerialise()
{
await using var host = await BootAsync();
var nm = host.Nm;
var unsNs = nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns);
nm.EnsureFolder("eq-3", parentNodeId: null, displayName: "Station 3", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-swap", "eq-3", "Swap", "OffNormalAlarm", 700,
realm: AddressSpaceRealm.Uns, isNative: false);
// Kind swap (scripted → native) drops the prior instance and re-creates it.
nm.MaterialiseAlarmCondition("alm-swap", "eq-3", "Swap", "OffNormalAlarm", 700,
realm: AddressSpaceRealm.Uns, isNative: true);
var condition = nm.TryGetAlarmCondition("alm-swap", AddressSpaceRealm.Uns);
condition.ShouldNotBeNull();
condition.EventType!.Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType);
condition.SourceNode!.Value.ShouldBe(new NodeId("alm-swap", unsNs));
condition.SourceName!.Value.ShouldBe("alm-swap");
}
/// <summary>#475 — a NATIVE condition (Raw realm) carries the mandatory ConditionType classification fields.
/// We do not model condition classes, so Part 9's "no class modelled" value — BaseConditionClassType — is the
/// conformant answer; the point is that it is a resolvable class NodeId a client can bucket on rather than a
/// null that drops the alarm into an unclassified bin.</summary>
[Trait("Category", "Unit")]
[Fact]
public async Task Native_condition_carries_ConditionClassId_and_ConditionClassName()
{
await using var host = await BootAsync();
var nm = host.Nm;
nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
nm.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, "HR200", "OffNormalAlarm", 700,
realm: AddressSpaceRealm.Raw, isNative: true);
var condition = nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw);
condition.ShouldNotBeNull();
condition.ConditionClassId.ShouldNotBeNull();
condition.ConditionClassId.Value.ShouldBe(ObjectTypeIds.BaseConditionClassType);
condition.ConditionClassName.ShouldNotBeNull();
condition.ConditionClassName.Value.ShouldNotBeNull();
condition.ConditionClassName.Value.Text.ShouldBe("BaseConditionClass");
}
/// <summary>#475 — a SCRIPTED condition (UNS realm) carries the SAME classification fields: native and scripted
/// share the materialise path, so neither may regress independently.</summary>
[Trait("Category", "Unit")]
[Fact]
public async Task Scripted_condition_carries_ConditionClassId_and_ConditionClassName()
{
await using var host = await BootAsync();
var nm = host.Nm;
nm.EnsureFolder("eq-4", parentNodeId: null, displayName: "Station 4", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("tank-dry", "eq-4", "Tank Dry", "OffNormalAlarm", 700,
realm: AddressSpaceRealm.Uns, isNative: false);
var condition = nm.TryGetAlarmCondition("tank-dry", AddressSpaceRealm.Uns);
condition.ShouldNotBeNull();
condition.ConditionClassId!.Value.ShouldBe(ObjectTypeIds.BaseConditionClassType);
condition.ConditionClassName!.Value.Text.ShouldBe("BaseConditionClass");
}
/// <summary>A booted server + its node manager, disposed via <c>await using</c> so an assertion failure
/// cannot leak a live server (and its bound port) into the rest of the test run.</summary>
private sealed class BootedServer(OpcUaApplicationHost host, OtOpcUaNodeManager nm) : IAsyncDisposable
{
public OtOpcUaNodeManager Nm { get; } = nm;
public ValueTask DisposeAsync() => host.DisposeAsync();
}
private async Task<BootedServer> BootAsync()
{
var host = new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.AlarmSourceFieldsTest",
ApplicationUri = $"urn:OtOpcUa.AlarmSourceFieldsTest:{Guid.NewGuid():N}",
OpcUaPort = AllocateFreePort(),
PublicHostname = "localhost",
PkiStoreRoot = _pkiRoot,
},
NullLogger<OpcUaApplicationHost>.Instance);
var server = new OtOpcUaSdkServer();
await host.StartAsync(server, Ct);
return new BootedServer(host, server.NodeManager!);
}
private static int AllocateFreePort()
{
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
listener.Start();
return ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
}
public void Dispose()
{
if (Directory.Exists(_pkiRoot))
{
try { Directory.Delete(_pkiRoot, recursive: true); }
catch { /* best-effort */ }
}
}
}
@@ -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);

Some files were not shown because too many files have changed in this diff Show More