Compare commits

...

52 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
Joseph Doherty 0c65e4412e docs(v3): Batch 3 PR description
v2-ci / build (pull_request) Successful in 3m36s
v2-ci / unit-tests (pull_request) Failing after 9m8s
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 07:38:20 -04:00
Joseph Doherty bdcb84bd7d docs(v3-batch3): UNS reference-only Equipment + {{equip}}/RefName resolution
Uns.md Tags section rewritten to the reference-only model (UnsTagReference list,
cluster-scoped picker, effective-name uniqueness, {{equip}}/RefName); CLAUDE.md
gains a v3 Batch 3 paragraph. ScriptEditor.md was updated by WP4.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 07:37:36 -04:00
Joseph Doherty 96af69e3d2 review(wave-b): close SA {{equip}} authoring gap (M1) + harden parity tests (L3)
MEDIUM-1: the editor<->authoring<->deploy invariant now holds on the ScriptedAlarm
surface too. CreateScriptedAlarmAsync/UpdateScriptedAlarmAsync validate {{equip}}/<RefName>
in BOTH the predicate script and the message template (via ExtractEquipReferenceNamesFromText),
matching what DraftValidator already enforces at deploy. Refactored ValidateEquipTokenAsync
into shared LoadEquipReferenceNamesAsync + CheckEquipReferencesResolve helpers.

LOW-1: LoadEquipReferenceNamesAsync uses IsNullOrEmpty (defense-in-depth) to match
EquipmentReferenceMap.Build; empty override is already normalized->null at the write path.

LOW-3: parity test hardened with two edge cases — unresolved-ref-left-intact (both seams
leave {{equip}}/X identical) and folder+tag-group RawPath ancestry.

Tests: VirtualTagEquipTokenValidationTests 9/9 (+3 SA), parity 4/4 (+2). AdminUI 659/0.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 07:18:59 -04:00
Joseph Doherty 29bb4f176e merge-fix(wave-b): dedupe BuildReference test helper (WP2+WP4 both added it)
Semantic collision git auto-merged: WP2 and WP4 each added a BuildReference
helper to the DraftValidatorTests partial class (same param types -> CS0111).
Kept one with the refId param name + default null, satisfying both call styles.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 07:03:21 -04:00
Joseph Doherty 940303276c merge worktree-agent-ad3207d913b3b74e6 (B3-WP4) into v3/batch3-uns-rework 2026-07-16 06:59:22 -04:00
Joseph Doherty dc0d7653b9 feat(v3-batch3): B3-WP4 — {{equip}}/<RefName> reference-relative resolution
Replace the deleted equipment-base-prefix {{equip}} mechanism (impossible now
that equipment is reference-only) with per-reference resolution:
{{equip}}/<RefName> resolves through the owning equipment's UnsTagReference rows
(by effective name) to the backing raw tag's RawPath. Every unresolved <RefName>
is a clear deploy error + authoring rejection + Monaco diagnostic — preserving
"editor accepts <=> publish accepts".

Commons:
- EquipmentScriptPaths: delete DeriveEquipmentBase; SubstituteEquipmentToken now
  takes the equipment's reference map (effectiveName -> RawPath) and substitutes
  {{equip}}/<RefName> inside path literals (unresolved left intact, never throws);
  add ExtractEquipReferenceNames (path-literal scoped) +
  ExtractEquipReferenceNamesFromText (message templates). Slash syntax replaces
  the v2 dot joint.
- New EquipmentReferenceMap: shared, input-shape-agnostic builder (entity + JSON
  sides) over RawPathResolver — the single authority both compose seams + the
  validator use, so resolved RawPaths agree byte-for-byte.

Compose seams (byte-parity kept):
- AddressSpaceComposer.Compose + DeploymentArtifact.ParseComposition both build
  the per-equipment reference map from UnsTagReferences + Tags + raw topology and
  substitute VirtualTag AND ScriptedAlarm-predicate sources before dependency
  extraction (runtime dep refs = resolved RawPaths).

Deploy gate + authoring + editor:
- DraftValidator.ValidateEquipReferenceResolution: EquipReferenceUnresolved error
  for unresolved {{equip}}/<RefName> in VirtualTag/ScriptedAlarm scripts + alarm
  message-template tokens (naming script, equipment, missing ref).
- UnsTreeService.ValidateEquipTokenAsync (Wave-A M1): reference-relative authoring
  rejection, replacing the stale DeriveEquipmentBase(empty)->reject-all logic.
- ScriptAnalysisService: {{equip}}/ completion offers the equipment's reference
  effective names; DiagnoseAsync flags unresolved refs (OTSCRIPT_EQUIPREF) same as
  the deploy gate. Requests carry optional EquipmentId (threaded MonacoEditor ->
  monaco-init.js -> fetch bodies). IScriptTagCatalog.GetEquipmentRelativeLeavesAsync
  repurposed to GetEquipmentReferenceNamesAsync(equipmentId, filter).

Tests: EquipmentScriptPaths substitution/extraction (slash pinned); composer<->
artifact reference-resolution byte-parity; DraftValidator unresolved-ref;
ScriptAnalysis completion+diagnostic; M1 authoring gate. Build clean (0/0); all
five affected suites green.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 06:58:17 -04:00
Joseph Doherty ac12eec924 review(wave-a L1): correct concurrency-catch comment (no DB uniqueness on effective name)
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 06:24:05 -04:00
Joseph Doherty ac8d4eef30 merge worktree-agent-aaae13cca624ecb2d into v3/batch3-uns-rework 2026-07-16 06:16:42 -04:00
Joseph Doherty 8c05a9fe0a merge worktree-agent-a2c703868462add4a into v3/batch3-uns-rework 2026-07-16 06:16:42 -04:00
Joseph Doherty b610a8dde5 merge worktree-agent-a10be14b6713011cd into v3/batch3-uns-rework 2026-07-16 06:16:42 -04:00
Joseph Doherty 77bc010ba9 v3 Batch 3 WP1: UNS Equipment Tags tab → reference list + raw-tag picker
Replace the Batch-1-stubbed authored-tag flow with a UnsTagReference list on the
equipment page's Tags tab (v3 reference-only equipment): columns are effective name,
computed raw path, inherited datatype/access (read-only), display-name override
(editable), and remove. The retired equipment-authored-tag editor (TagModal.razor)
is deleted; the VirtualTag and ScriptedAlarm flows are untouched.

- "+ Add reference" opens a new AddReferenceModal that reuses RawTree in a new opt-in
  PickerMode (Tag-leaf checkboxes + Device/TagGroup "select all tags below"), scoped
  structurally to the equipment's cluster via LoadReferencePickerRootAsync (a single
  cluster root — cross-cluster tags are unreachable, not merely hidden). Default /raw
  usage is byte-unchanged (PickerMode defaults false).
- UnsTreeService (+ IUnsTreeService) gain the reference mutations:
  LoadReferencesForEquipmentAsync (computes each RawPath via the shared RawPathResolver),
  AddReferencesAsync (cluster-checked, all-or-nothing), RemoveReferenceAsync,
  SetReferenceOverrideAsync, plus the picker helpers LoadReferencePickerRootAsync /
  LoadDescendantTagIdsAsync.
- Consume IEffectiveNameGuard (constructor-injected, no-op fallback when unregistered):
  AddReferences, SetReferenceOverride, and the VirtualTag + ScriptedAlarm create/update
  mutations now call CheckAsync before persisting and surface a collision as the failure.
  WP2 supplies the implementation + DI registration.
- Drop the retired equipment↔driver binding: remove DriverInstanceId from EquipmentInput,
  the ImportEquipmentModal CSV column, the EquipmentPage Details driver select, and the
  dead decision-#122 driver-cluster guard.

Tests: new UnsTreeServiceReferenceTests (add/remove/override, cross-cluster + duplicate
rejection, guard-consumed rejection via a fake guard, RawPath projection, picker helpers);
Equipment/Import test suites rewritten to drop the retired driver-guard cases. AdminUI
suite green (639 passed, 3 skipped); full solution builds 0 errors.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 06:15:51 -04:00
Joseph Doherty 09ff43910c feat(raw): B3-WP3 rename-warning script scan + historized-override refine
Extend RawTreeService.BuildRenameWarningsAsync so a tag/ancestor rename now
raises three warning kinds over the affected (prefix-scanned) tags:

(a) refined — historized WITHOUT a historianTagname override (default tagname
    is the moving RawPath, so history forks); a pinned (override) tag no longer
    warns. Count/message adjusted.
(b) unchanged — UNS-referenced (names the equipment).
(c) NEW — substring-scan every Script body for the affected tag's OLD RawPath
    (ordinal, false-positives tolerated by design; no AST). OLD RawPaths are
    recomputed from the pre-save in-DB topology via RawPathResolver.

CollectTagsBeneath* + TagsForDevices now carry (DeviceId, TagGroupId, Name) so
the OLD RawPath can be rebuilt. Contained entirely within RawTreeService.cs;
IRawTreeService + RawTree.razor untouched (warnings flow through the existing
RawRenameResult.Warnings). Note: a direct tag rename goes through UpdateTagAsync
(UnsMutationResult, no warning surface) so it is out of scope without widening
the interface.

Tests: new RawTreeServiceRenameWarningTests (4) — all-three, pinned-no-warn,
unrelated-empty, ancestor-rename script match. AdminUI.Tests 642 green.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 06:04:06 -04:00
Joseph Doherty b68c313372 feat(uns-v3): implement EffectiveNameGuard authoring-time uniqueness guard (B3-WP2)
Implements IEffectiveNameGuard (the Wave-A contract): per-equipment effective-name
uniqueness across UnsTagReferences (DisplayNameOverride else backing raw tag Name),
VirtualTags (Name), and ScriptedAlarms (Name). Ordinal comparison, self-row exclusion
by (kind, id), one pooled context per CheckAsync call. Registered Scoped in
EndpointRouteBuilderExtensions so WP1's UnsTreeService consumer goes live.

Verified the deploy-time DraftValidator UnsEffectiveNameCollision rule already
computes reference effective names from the backing raw tag's current Name (catches
rename-induced collisions), spans all three sources, compares ordinal, and names both
colliding sources + the equipment — no hardening needed. DraftSnapshotFactory already
loads Tags/UnsTagReferences/VirtualTags/ScriptedAlarms.

Tests: 8 guard tests (in-memory EF) + 3 deploy-gate tests (rename-induced collision,
override-vs-VT, ordinal case-sensitivity). All green.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 06:01:10 -04:00
Joseph Doherty 6dc5af7aa2 v3 Batch 3 contracts: IEffectiveNameGuard authoring-time uniqueness seam
Wave-A shared contract. WP2 implements + registers the guard; WP1 consumes it
in UnsTreeService reference/VirtualTag/ScriptedAlarm mutations. Ordinal, mirrors
the deploy-time DraftValidator UnsEffectiveNameCollision rule.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 05:53:52 -04:00
dohertj2 a88dc86173 Merge pull request 'v3 Batch 2 — /raw project-tree AdminUI + Calculation driver' (#470) from v3/batch2-raw-ui into master
v2-ci / build (push) Successful in 3m29s
v2-ci / unit-tests (push) Failing after 8m38s
2026-07-16 05:50:26 -04:00
157 changed files with 10713 additions and 3014 deletions
+43 -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 gRPC session. The gateway owns the COM apartment + STA pump
server-side; the driver speaks `MxCommand` / `MxEvent` protos server-side; the driver speaks `MxCommand` / `MxEvent` protos
exclusively. exclusively.
3. **OPC UA Server** — Exposes authored equipment tags as variable nodes. 3. **OPC UA Server** — Exposes the deployed tree under **two OPC UA namespaces**
Galaxy tags are bound by `TagConfig.FullName` (`tag_name.AttributeName`); (see below). Galaxy tags are bound by `TagConfig.FullName`
reads/writes/subscriptions are translated to that reference for MXAccess. (`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 ### Key Concept: Tag Name and FullName
@@ -217,6 +253,10 @@ The AdminUI's global **UNS** page (`/uns`) is the single surface for managing th
**v3 (Batch 2): device I/O is authored in the `/raw` project tree** (`GlobalRaw.razor``RawTree.razor`, `IRawTreeService`) — a cluster-rooted `Folder → Driver → Device → TagGroup → Tag` hierarchy with per-node context menus (the reusable `ContextMenu`), lazy loading, driver/device config modals (endpoint moved to `Device.DeviceConfig`; Test-connect probes the `DriverDeviceConfigMerger`-merged config), manual-entry + CSV import/export (RFC-4180, staged review grid, all-or-nothing), and browse-commit of discovered leaves into raw tags. The routed `/clusters/{id}/drivers` flow (`DriverTypePicker`, `DriverEditRouter`, the 8 `*DriverPage` shells, the Drivers list) is **retired** — its form bodies live on inside the `/raw` modals. The `DriverType` dispatch maps are keyed off the `DriverTypeNames` constants (fixing the `TwinCat`/`Focas` drift). A `Calculation` pseudo-driver (`DriverTypeNames.Calculation`, `IDependencyConsumer`, deploy-time `scriptId`-existence + Tarjan cycle gates) computes signal-level tags from other tags' RawPaths. See `docs/Raw.md`. **v3 (Batch 2): device I/O is authored in the `/raw` project tree** (`GlobalRaw.razor``RawTree.razor`, `IRawTreeService`) — a cluster-rooted `Folder → Driver → Device → TagGroup → Tag` hierarchy with per-node context menus (the reusable `ContextMenu`), lazy loading, driver/device config modals (endpoint moved to `Device.DeviceConfig`; Test-connect probes the `DriverDeviceConfigMerger`-merged config), manual-entry + CSV import/export (RFC-4180, staged review grid, all-or-nothing), and browse-commit of discovered leaves into raw tags. The routed `/clusters/{id}/drivers` flow (`DriverTypePicker`, `DriverEditRouter`, the 8 `*DriverPage` shells, the Drivers list) is **retired** — its form bodies live on inside the `/raw` modals. The `DriverType` dispatch maps are keyed off the `DriverTypeNames` constants (fixing the `TwinCat`/`Focas` drift). A `Calculation` pseudo-driver (`DriverTypeNames.Calculation`, `IDependencyConsumer`, deploy-time `scriptId`-existence + Tarjan cycle gates) computes signal-level tags from other tags' RawPaths. See `docs/Raw.md`.
**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`. 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 ## 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.Abstractions" Version="0.1.1" />
<PackageVersion Include="ZB.MOM.WW.Auth.Ldap" 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.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.Audit" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Theme" Version="0.3.1" /> <PackageVersion Include="ZB.MOM.WW.Theme" Version="0.3.1" />
<PackageVersion Include="ZB.MOM.WW.HistorianGateway.Client" Version="0.3.0" /> <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.Theme" />
<package pattern="ZB.MOM.WW.HistorianGateway.Contracts" /> <package pattern="ZB.MOM.WW.HistorianGateway.Contracts" />
<package pattern="ZB.MOM.WW.HistorianGateway.Client" /> <package pattern="ZB.MOM.WW.HistorianGateway.Client" />
<package pattern="ZB.MOM.WW.Secrets" />
<package pattern="ZB.MOM.WW.Secrets.*" />
</packageSource> </packageSource>
</packageSourceMapping> </packageSourceMapping>
</configuration> </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 because it carries the full operator + raise-time + category metadata
that the value-driven path collapses. 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) ## Galaxy driver path (driver-native)
Restored in PR B.2 of the epic. `GalaxyDriver` implements 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 `Historizing=true` and `AccessLevels.HistoryRead` are set at materialization so any compliant
OPC UA client can discover historized capability from the node's attributes. 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 **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 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 `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 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. 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 ```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 \ otopcua-cli historyread \
-u opc.tcp://localhost:4840/OtOpcUa \ -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" --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 \ otopcua-cli historyread \
-u opc.tcp://localhost:4840/OtOpcUa \ -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" \ --start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \
--max 100 --max 100
# 1-hour average aggregate # 1-hour average aggregate
otopcua-cli historyread \ otopcua-cli historyread \
-u opc.tcp://localhost:4840/OtOpcUa \ -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" \ --start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \
--aggregate Average --interval 3600000 --aggregate Average --interval 3600000
# Authenticated read (ReadOnly role or higher required) # Authenticated read (ReadOnly role or higher required)
otopcua-cli historyread \ otopcua-cli historyread \
-u opc.tcp://localhost:4840/OtOpcUa \ -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" \ --start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \
-U reader -P password -U reader -P password
``` ```
+59 -40
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 `DbContext` via the pooled factory (same pattern as `UnsTreeService`). Results
are bounded to 200 entries to keep the completion list responsive on large fleets. 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 ### Literal gate
`TryGetTagPathLiteral` identifies the tag-path context by climbing from the `TryGetTagPathLiteral` identifies the tag-path context by climbing from the
@@ -307,76 +314,88 @@ Register the replacement in `EndpointRouteBuilderExtensions.AddAdminUI`
### Why ### Why
Today each VirtualTag bound to a script typically needs its own near-duplicate Each VirtualTag bound to a script would otherwise need its own near-duplicate
script because tag paths are hard-coded absolutes (e.g. `TestMachine_001.Speed`). script because tag paths are hard-coded absolute RawPaths (e.g.
The `{{equip}}` token breaks this coupling: point many VirtualTags' `ScriptId` at `Cell1/Modbus/Dev1/Speed`). The `{{equip}}/<RefName>` token breaks this coupling:
a single template script, and each resolves the token to its own equipment's tag point many VirtualTags' `ScriptId` at a single template script, and each resolves
base prefix at deploy time. No schema change is required — sharing a `Script` the token through **its own equipment's tag references** at deploy time. No schema
record across VirtualTags already works; `{{equip}}` is what makes the shared change is required — sharing a `Script` record across VirtualTags already works;
script resolve per-equipment. `{{equip}}/<RefName>` is what makes the shared script resolve per-equipment.
### Before / after ### Before / after
**Before — one script per machine:** **Before — one script per machine (hard-coded RawPath):**
```csharp ```csharp
// Script "Calc_TestMachine_001" — hard-coded, cannot reuse // Script "Calc_TestMachine_001" — hard-coded, cannot reuse
return ctx.GetTag("TestMachine_001.Speed").Value; return ctx.GetTag("Cell1/Modbus/Dev1/Speed").Value;
``` ```
**After — one shared template:** **After — one shared template:**
```csharp ```csharp
// Script "Calc_Speed" — works for any machine // Script "Calc_Speed" — works for any machine
return ctx.GetTag("{{equip}}.Speed").Value; return ctx.GetTag("{{equip}}/Speed").Value;
``` ```
`TestMachine_001` and `TestMachine_002` both bind `ScriptId = "Calc_Speed"`. `TestMachine_001` and `TestMachine_002` both bind `ScriptId = "Calc_Speed"`, and
At deploy, each VirtualTag receives its own expanded copy: each has a **tag reference** whose effective name is `Speed`. At deploy, each
`TestMachine_001.Speed` and `TestMachine_002.Speed` respectively. VirtualTag receives its own expanded copy — `{{equip}}/Speed` resolves to that
equipment's referenced raw tag's RawPath.
### Token rules ### Token rules (v3)
- The token is `{{equip}}` (double braces, lowercase). - The token joint is a **slash**: `{{equip}}/<RefName>` (double-brace stem,
lowercase). `<RefName>` is a **reference effective name** — the reference's
`DisplayNameOverride`, else the backing raw tag's `Name`.
- It is substituted **only inside `ctx.GetTag(…)` / `ctx.SetVirtualTag(…)` first-argument - It is substituted **only inside `ctx.GetTag(…)` / `ctx.SetVirtualTag(…)` first-argument
string literals** — comments, logger strings, and other code are untouched. string literals** — comments, logger strings, and other code are untouched.
- The operator writes the separator and tail: `ctx.GetTag("{{equip}}.Speed")`, - The whole post-prefix literal content is the reference name, so effective names
`ctx.GetTag("{{equip}}.Sub.Field")`, etc. with internal spaces are supported: `ctx.GetTag("{{equip}}/Motor Speed")`.
- The token expands to the equipment's **tag base prefix** — the common - `{{equip}}/<RefName>` **resolves through the owning equipment's `UnsTagReference`
substring-before-the-first-dot of that equipment's configured driver-tag rows** (by effective name) to the backing raw tag's `RawPath`. Example: a
`FullName` values. Example: tags `TestMachine_001.Speed` and reference named `Speed` backing `Cell1/Modbus/Dev1/Speed` → the token expands to
`TestMachine_001.Temp` → base `TestMachine_001`. `Cell1/Modbus/Dev1/Speed`.
- Scripted-alarm predicate scripts resolve `{{equip}}/<RefName>` the same way; alarm
**message-template** tokens follow the same resolution rule.
### Validation requirement ### Validation requirement
Saving a VirtualTag that is bound to a `{{equip}}`-using script is rejected in Saving a VirtualTag (or ScriptedAlarm) whose script uses `{{equip}}/<RefName>` is
the AdminUI if the equipment does not have at least one configured driver tag, or rejected in the AdminUI when `<RefName>` is not one of the owning equipment's
if its tags span multiple object prefixes (i.e. the common prefix is ambiguous or reference effective names. The same rule runs at the deploy gate
absent). The rejection message is surfaced as a clear validation error on the save (`DraftValidator.ValidateEquipReferenceResolution`, error code
form. This check is enforced eagerly so that an unresolved `{{equip}}` token — `EquipReferenceUnresolved`), which also catches a **rename-induced** breakage the
which would leave a path that resolves to nothing at runtime (Bad quality) — can authoring check never saw. The rejection names the script/alarm, the equipment, and
never reach the deployed artifact. the missing ref — so an unresolved token can never reach the deployed artifact.
### Editor support ### Editor support
In the Monaco script editor (ScriptEdit page and the `/uns` virtual-tag modal's In the Monaco script editor (ScriptEdit page and the `/uns` virtual-tag modal's
inline script panel): inline script panel):
- **Hover** — hovering a `{{equip}}` path literal shows an - **Hover** — hovering a `{{equip}}/<RefName>` path literal shows an
*"Equipment-relative path — resolved at deploy"* note. *"Equipment-relative path — resolved through the equipment's tag reference at
- **Completions** — typing `{{equip}}.` inside a `GetTag`/`SetVirtualTag` literal deploy"* note.
offers completion of attribute leaf names (the part after the first dot of known - **Completions** — typing `{{equip}}/` inside a `GetTag`/`SetVirtualTag` literal
tag references in the catalog). offers the owning equipment's **reference effective names** (needs an equipment
context; inert on the shared ScriptEdit page).
- **Diagnostics** — an unresolved `{{equip}}/<RefName>` is flagged (`OTSCRIPT_EQUIPREF`)
identically to the deploy gate, preserving *editor accepts ⇔ publish accepts*.
### Maintainer note ### Maintainer note
Substitution runs at the two compose seams — Substitution runs at the two compose seams — `AddressSpaceComposer.Compose` and
`Phase7Composer.Compose` and `DeploymentArtifact.BuildEquipmentVirtualTagPlans` `DeploymentArtifact.ParseComposition` — via the shared
— via the shared `ZB.MOM.WW.OtOpcUa.Commons.Types.EquipmentScriptPaths` helper, `ZB.MOM.WW.OtOpcUa.Commons.Types.EquipmentScriptPaths.SubstituteEquipmentToken`
**before** dependency extraction. The runtime, the static change-trigger helper, fed the equipment's reference map (effective name → RawPath) built by
dependency graph, and the literal-only path rule are therefore all unchanged: `EquipmentReferenceMap` over the shared `RawPathResolver`. Substitution runs
by the time they see the script, `{{equip}}` has been replaced with a concrete **before** dependency extraction, so the runtime, the static change-trigger
tag-base prefix and the path is a normal string literal. dependency graph, and the literal-only path rule are unchanged: by the time they
see the script, `{{equip}}/<RefName>` has been replaced with a concrete RawPath and
the path is a normal string literal. An unresolved ref is left un-substituted (the
validator rejects it first — substitution never throws). The two seams build the
reference map identically, so their plans stay byte-parity.
--- ---
+21 -4
View File
@@ -127,9 +127,25 @@ object alongside the usual `"FullName"`:
(unchanged behaviour). `"alarm"` **present** → the tag materialises as a Part 9 (unchanged behaviour). `"alarm"` **present** → the tag materialises as a Part 9
`AlarmConditionState` under its equipment folder **instead of** a value variable. `AlarmConditionState` under its equipment folder **instead of** a value variable.
No EF/schema change is required; the intent rides in the schemaless `TagConfig` 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. (`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 ### TagConfig alarm fields
| Field | Values | Default | | 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 the OPC UA condition-node write is ungated on all nodes so a Secondary stays warm
for failover. for failover.
The alarm is authored on the `Tags` tab of the equipment page (`/uns/equipment/{id}`) The alarm is authored on the **raw tag** in `/raw` by including the `"alarm"` object in the
by editing the tag's raw `TagConfig` JSON to include the `"alarm"` object. No tag's `TagConfig` JSON (v3 Batch 4 — equipment no longer authors tags; it references raw
other configuration is required. 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 ### Native-alarm OPC UA operator operations
+74 -12
View File
@@ -74,26 +74,88 @@ changed by editing the area's cluster in the Area modal, which moves the
whole branch. There is no separate "served-by" concept and no migration — whole branch. There is no separate "served-by" concept and no migration —
it is simply `UnsArea.ClusterId`. it is simply `UnsArea.ClusterId`.
### Tags ### Tags — reference-only (v3)
Tags created on the equipment page are **equipment-bound** and require a driver > **v3 (Batch 3):** UNS equipment no longer authors or binds tags. Device I/O is
instance. The driver list on the Tags tab is scoped to the equipment's cluster > authored once in the **Raw project tree** (`/raw` — see [`Raw.md`](Raw.md)); an
and to drivers on an **Equipment-kind** namespace, so a driver-less equipment > equipment's **Tags** tab holds **references** to those raw tags. The old
shows no eligible drivers until you bind one (edit the equipment on the Details > driver-bound Tag modal on this tab is retired.
tab and pick a driver).
**Galaxy / AVEVA System Platform points are ordinary equipment tags** bound to The **Tags** tab is a list of `UnsTagReference` rows. Each row shows the
a `GalaxyMxGateway` driver instance. Author them on the Tags tab using the **effective name**, the backing tag's **RawPath**, its inherited **DataType** and
standard Tag modal; the Galaxy address picker browses the live Galaxy hierarchy **AccessLevel** (read-only — they come from the raw tag), and an editable
so you can select the attribute and set `TagConfig.FullName`. There is no **display-name override**. The effective name is the override else the raw tag's
separate alias concept or `SystemPlatform`-kind namespace. `Name`.
**"+ Add reference"** opens a raw-tree picker (the `/raw` tree in picker mode)
**scoped to the equipment's cluster** — cross-cluster tags are structurally
unreachable, and the service also enforces `tag.cluster == equipment.cluster` on
commit. Tick individual tag leaves, or use a device / tag-group's *"Select all
tags below"* menu to pull many at once.
**Effective-name uniqueness:** within an equipment the effective name must be
unique across references, VirtualTags, and ScriptedAlarms (they share the
equipment's UNS NodeId space, `{EquipmentId}/{EffectiveName}`). This is enforced
both at authoring (a readable rejection naming the colliding source) and at the
deploy gate (`DraftValidator`, `UnsEffectiveNameCollision`) — the deploy gate is
what catches **rename-induced** collisions a raw rename produced after the
reference was authored, naming both colliding sources and the equipment.
Removing a raw tag in `/raw` is blocked while a `UnsTagReference` points at it
(the error names the referencing equipment); renaming a raw tag or any ancestor
warns when a beneath-it tag is historized (no `historianTagname` override),
UNS-referenced, or named by a script literal — see [`Raw.md`](Raw.md).
**Galaxy / AVEVA System Platform points** are now ordinary raw tags on a
`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 ### Virtual tags
A virtual tag is bound to an equipment and driven by a **script** (no driver). A virtual tag is bound to an equipment and driven by a **script** (no driver).
Add and edit virtual tags on the equipment page's **Virtual Tags** tab; the Add and edit virtual tags on the equipment page's **Virtual Tags** tab; the
data type is chosen from the standard OPC UA type list and the Monaco script data type is chosen from the standard OPC UA type list and the Monaco script
editor is available inline. editor is available inline. Scripts may read the equipment's references
relative-to-equipment with **`ctx.GetTag("{{equip}}/<RefName>")`** — the token
resolves through the equipment's `UnsTagReference` rows (by effective name) to
the backing raw tag's RawPath at deploy; an unresolved `<RefName>` is a deploy
error and a live Monaco diagnostic (editor accepts ⇔ publish accepts). See
[`ScriptEditor.md`](ScriptEditor.md).
### Galaxy tags ### Galaxy tags
@@ -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.
+89
View File
@@ -0,0 +1,89 @@
# v3 Batch 3 — UNS reference-only Equipment + `{{equip}}/<RefName>` reference-relative resolution
Implements `docs/plans/2026-07-15-v3-batch3-uns-rework-plan.md` (WP1WP4), executed via the
`/v3-batch 3` 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 5-item live gate on
docker-dev.
## What landed
- **Contracts**`IEffectiveNameGuard` (authoring-time effective-name uniqueness seam; WP2 implements,
WP1 consumes).
- **Wave A** (3 parallel agents):
- **WP1** — equipment **Tags** tab → `UnsTagReference` list (effective name / RawPath / inherited
DataType+AccessLevel / display-name override); new `AddReferenceModal` reusing `RawTree` in opt-in
`PickerMode` (multi-select tag leaves + device/tag-group "Select all tags below"), **cluster-scoped**
(structural + server-enforced `tag.cluster == equipment.cluster`); `UnsTreeService` reference CRUD;
consumes `IEffectiveNameGuard` in all colliding mutations; `ImportEquipmentModal`/`EquipmentInput`
dropped `DriverInstanceId`; deleted the retired equipment `TagModal`.
- **WP2**`EffectiveNameGuard` (injectable, ordinal) + DI registration; verified the deploy-gate
`UnsEffectiveNameCollision` rule (already computes ref effective name as override-else-current-raw-name,
so it catches rename-induced collisions; ordinal; names both sources + equipment).
- **WP3**`RawTreeService.BuildRenameWarningsAsync`: refined historized warning (only tags **without**
a `historianTagname` override) + UNS-referenced + **new** substring scan of every `Script.SourceCode`
for affected tags' OLD RawPaths (recomputed pre-save via `RawPathResolver`, ordinal).
- **Wave B** (1 integration agent):
- **WP4**`{{equip}}/<RefName>` reference-relative resolution. `EquipmentScriptPaths.DeriveEquipmentBase`
deleted; slash joint replaces the dot joint; new shared `EquipmentReferenceMap` (`equipmentId →
effectiveName → RawPath`) built identically at both compose seams (`AddressSpaceComposer` +
`DeploymentArtifact`, byte-parity). Unresolved-ref deploy error (`EquipReferenceUnresolved`) covering VT
scripts, SA predicates, and SA message-template tokens; authoring guard (`ValidateEquipTokenAsync`,
the Wave-A M1 fix); Monaco `{{equip}}/` completion of reference effective names + unresolved diagnostic
(`OTSCRIPT_EQUIPREF`), `EquipmentId` threaded request→model→JS.
**Address space stays dark** (values light up in Batch 4). Full solution builds 0 errors; Commons **309**,
Configuration **121**, AdminUI **659**, OpcUaServer **335/4-skip**, Runtime **361/42-skip** (the skips are
the Batch-1 dark-address-space tests Batch 4 un-skips).
## Reviewer findings fixed in-branch
- **Wave A** — no HIGH. Verified: the register-AND-consume guard seam (prod DI injects the real guard; the
NoOp fallback only wins under `new` in tests; guard invoked in all 6 colliding mutations); cluster scoping
structural + server-enforced; Razor default `RawTree` byte-unchanged; WP3 pre-save RawPath recomputation.
L1 (misleading concurrency comment) fixed.
- **Wave B** — no HIGH; byte-parity + production wiring confirmed. **M1** fixed: the editor⇔authoring⇔deploy
invariant now holds on the **ScriptedAlarm** surface too (SA predicate + message-template `{{equip}}` refs
validated at authoring, matching the deploy gate). **L3** fixed: parity test hardened (unresolved-ref-intact
+ folder/tag-group RawPath ancestry). L1 already closed at the write path (empty override normalized→null).
- **Deferred (documented follow-ups):** absolute-path Monaco completion still projects the raw leaf name, not
the RawPath (out of `{{equip}}` scope; `{{equip}}/` completion works) — rework `ScriptTagCatalog` to RawPaths
in Batch 4; a broken raw-topology-chain reference is deploy-accepted but compose-dropped (low reachability);
message-template `{{equip}}/X` is gate-validated but rendered/substituted only in Batch 4.
## Live `/run` gate (docker-dev `:9200`, both central nodes rebuilt on Batch-3 code)
Substrate: an Area→Line→Equipment seeded in MAIN, plus a SITE-A raw tag so cross-cluster exclusion is
demonstrable. (Hand-seeded equipment surfaced the Batch-1 `EquipmentIdNotDerived` invariant — the canonical
`EQ-<hash>` id was applied before the gate.)
1. **Reference picker + list** — "+ Add reference" header *"The tree is scoped to the equipment's cluster"*;
only the **Main cluster** root shows (SITE-A/B absent — the seeded `SiteAOnly` tag unreachable); lazy
expansion; a device **"Select all tags below"** pulled tags across a collapsed group → **4 references in
one commit**. Rows show effective name / RawPath (incl. deep `opcua1/plc/OpcPlc/Telemetry/Basic/…`
ancestry) / DataType / Access / override. Set override "MainPressure" → effective name updated
`HR200 → MainPressure`, RawPath unchanged.
2. **Collision***authoring:* setting a reference override to an existing VirtualTag's name → red banner
**"effective name 'GateVt' already used by VirtualTag 'VT-gatevt' in equipment '…'"** (not persisted).
*rename-induced:* renamed a raw tag so two references collide (allowed at rename) → deploy **422**
`[UnsEffectiveNameCollision] 2 UNS signals collide on effective name 'ImportedTag' … reference '…',
reference '…'`.
3. **`{{equip}}` (editor ⇔ deploy)** — resolving `{{equip}}/MainPressure` deploys **202**; misspelled
`{{equip}}/MainPresure` deploys **422** `[EquipReferenceUnresolved] … has no reference named 'MainPresure'`.
Monaco: red squiggle + hover *"'{{equip}}/MainPresure' does not resolve — … no reference named
'MainPresure' … (OTSCRIPT_EQUIPREF)"*; typing `{{equip}}/` completes the four reference **effective**
names (AlternatingBoolean, ImportedTag, **MainPressure**, RandomSignedInt32).
4. **Rename warning** — renaming a driver whose beneath-it tag is historized-without-override + UNS-referenced
+ named in a script literal → **"Renamed — with warnings"** listing all three (historized fork,
UNS-referenced by 'gateequip', "2 scripts ('cval','gatevt') reference tags … by their raw paths"); renaming
an unrelated driver → **no warning dialog**. (Note: direct *tag* rename flows through `UpdateTagAsync`
which has no warnings surface — warnings fire on container renames affecting descendants; documented
follow-up if per-tag-rename warnings are wanted.)
5. **Import without driver column** — the Import equipment CSV modal columns are `Name, MachineCode, UnsLineId`
(+ optional `ZTag, SAPID, Manufacturer, Model`); **no `DriverInstanceId`**.
## Docs
`docs/Uns.md` Tags section rewritten to the reference-only model; `docs/ScriptEditor.md` updated (WP4) to the
slash/reference `{{equip}}` semantics; `CLAUDE.md` gains a v3 Batch 3 paragraph.
https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
+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 ## Troubleshooting
### Certificate trust failure ### 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="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="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="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( public sealed record AlarmTransitionEvent(
string AlarmId, string AlarmId,
string EquipmentPath, string EquipmentPath,
@@ -28,4 +29,5 @@ public sealed record AlarmTransitionEvent(
DateTime TimestampUtc, DateTime TimestampUtc,
string AlarmTypeName = "AlarmCondition", string AlarmTypeName = "AlarmCondition",
string? Comment = null, 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; _inner = sink ?? NullOpcUaAddressSpaceSink.Instance;
/// <inheritdoc /> /// <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)
=> _inner.WriteValue(nodeId, value, quality, sourceTimestampUtc); => _inner.WriteValue(nodeId, value, quality, sourceTimestampUtc, realm);
/// <inheritdoc /> /// <inheritdoc />
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
=> _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc); => _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm);
/// <inheritdoc /> /// <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)
=> _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative); => _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative);
/// <inheritdoc /> /// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) // Forward the WP4 multi-notifier wiring to the inner sink. Without this the native-alarm fan-out to the
=> _inner.EnsureFolder(folderNodeId, parentNodeId, displayName); // 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 /> /// <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)
=> _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength); => _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 /> /// <inheritdoc />
public void RebuildAddressSpace() => _inner.RebuildAddressSpace(); public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
/// <inheritdoc /> /// <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 /> /// <inheritdoc />
// Forwards to the inner sink when it supports the surgical capability; returns false otherwise — // 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 // 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 // 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. // 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 => _inner is ISurgicalAddressSpaceSink surgical
&& surgical.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength); && surgical.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength, realm);
/// <inheritdoc /> /// <inheritdoc />
// Forwards to the inner sink when it supports the surgical capability; returns false otherwise — // 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. // 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 // Without this forward the rename-refresh optimization is inert on every driver-role host, because
// actors inject THIS wrapper, not the inner sink. // 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 => _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 / // R2-07 T7 — surgical remove forwards. Same capability-sniffing pattern as UpdateTagAttributes /
// UpdateFolderDisplayName: forward when the inner sink supports the surgical capability; return false // 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 // (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. // inert on every driver-role host — the F10b / PR#423 trap the reflection guard exists to catch.
/// <inheritdoc /> /// <inheritdoc />
public bool RemoveVariableNode(string variableNodeId) public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm)
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveVariableNode(variableNodeId); => _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveVariableNode(variableNodeId, realm);
/// <inheritdoc /> /// <inheritdoc />
public bool RemoveAlarmConditionNode(string alarmNodeId) public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm)
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveAlarmConditionNode(alarmNodeId); => _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveAlarmConditionNode(alarmNodeId, realm);
/// <inheritdoc /> /// <inheritdoc />
public bool RemoveEquipmentSubtree(string equipmentNodeId) public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm)
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveEquipmentSubtree(equipmentNodeId); => _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="value">The value to write.</param>
/// <param name="quality">The quality status of the value.</param> /// <param name="quality">The quality status of the value.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</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 /// <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"/>, /// 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="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="state">The full condition state to project onto the node.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</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> /// <summary>
/// Materialise a real OPC UA Part 9 alarm-condition node under its equipment folder so clients /// 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 /// <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 /// 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> /// 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> /// <summary>
/// Ensure a folder node exists under the given parent. Used by <c>AddressSpaceApplier</c> to /// 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="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="parentNodeId">The parent folder node ID, or null for namespace root.</param>
/// <param name="displayName">The display name for the folder.</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> /// <summary>
/// Ensure a Variable node exists at <paramref name="variableNodeId"/>, parented under /// 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> /// rank + dimensions carry the array-ness.</param>
/// <param name="arrayLength">The declared length of the 1-D array when <paramref name="isArray"/> is /// <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> /// 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> /// <summary>
/// Tear down + repopulate the address space. Called by <c>OpcUaPublishActor</c> after a /// 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). /// (Part 3 GeneralModelChangeEvent, verb NodeAdded).
/// </summary> /// </summary>
/// <param name="affectedNodeId">The node under which discovered nodes were added.</param> /// <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 /// <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() { } private NullOpcUaAddressSpaceSink() { }
/// <inheritdoc /> /// <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 /> /// <inheritdoc />
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { } public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
/// <inheritdoc /> /// <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 /> /// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { } public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
/// <inheritdoc /> /// <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 /> /// <inheritdoc />
public void RebuildAddressSpace() { } public void RebuildAddressSpace() { }
/// <inheritdoc /> /// <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 /// <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> /// <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="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> /// <param name="ct">Cancellation token.</param>
/// <returns>A task resolving to the device-write outcome.</returns> /// <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> /// <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(); public static readonly NullOpcUaNodeWriteGateway Instance = new();
private NullOpcUaNodeWriteGateway() { } private NullOpcUaNodeWriteGateway() { }
/// <inheritdoc /> /// <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")); 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="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="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="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> /// <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 /// <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 /// (ClearChangeMasks) without a rebuild — used by AddressSpaceApplier to apply a UNS Area / Line
@@ -32,8 +34,9 @@ public interface ISurgicalAddressSpaceSink
/// should rebuild instead).</summary> /// should rebuild instead).</summary>
/// <param name="folderNodeId">The node id of the folder whose display name to update in place.</param> /// <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="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> /// <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 /// <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 /// 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 /// 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> /// (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="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> /// <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 /// <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 /// 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 /// stops replaying on ConditionRefresh. Returns false when the condition id is unknown (caller
/// rebuilds).</summary> /// rebuilds).</summary>
/// <param name="alarmNodeId">The alarm condition node id (== ScriptedAlarmId, or a native alarm tag's folder-scoped id).</param> /// <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> /// <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, /// <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, /// 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 /// the equipment folder outside the lock. Returns false when the folder id is unknown (caller
/// rebuilds).</summary> /// rebuilds).</summary>
/// <param name="equipmentNodeId">The equipment folder node id whose entire subtree to remove.</param> /// <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> /// <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);
}
}
@@ -0,0 +1,94 @@
namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
/// <summary>
/// Builds the per-equipment reference map — <c>equipmentId → (effectiveName → backing-tag RawPath)</c> —
/// the single authority both compose seams (<c>AddressSpaceComposer</c> + <c>DeploymentArtifact</c>)
/// and the draft validator use to resolve <c>{{equip}}/&lt;RefName&gt;</c> script paths. A reference's
/// <b>effective name</b> is its <c>DisplayNameOverride</c> else the backing raw tag's <c>Name</c>; the
/// RawPath is computed through the shared <see cref="RawPathResolver"/> (the same identity authority the
/// rest of v3 uses), so the entity side (authoring/validation) and the artifact-decode side agree
/// byte-for-byte.
/// <para>Input-shape agnostic + pure: callers flatten their references (EF entities on one side,
/// artifact JSON on the other) into the same primitive tuple + tag lookup, so the join, the
/// effective-name rule, and the collision policy live ONLY here. References are consumed in
/// <c>UnsTagReferenceId</c>-ordinal order and effective-name collisions keep the FIRST — a valid draft
/// has none (the uniqueness validator rejects them), and the deterministic first-wins keeps the two
/// seams parity-stable on any input.</para>
/// </summary>
public static class EquipmentReferenceMap
{
/// <summary>A flattened UNS tag reference: which equipment references which raw tag under an optional override.</summary>
/// <param name="UnsTagReferenceId">Stable logical id — the ordering key for deterministic first-wins.</param>
/// <param name="EquipmentId">The referencing equipment.</param>
/// <param name="TagId">The backing raw tag.</param>
/// <param name="DisplayNameOverride">Optional effective-name override; <see langword="null"/> = use the raw tag's Name.</param>
public readonly record struct ReferenceRow(string UnsTagReferenceId, string EquipmentId, string TagId, string? DisplayNameOverride);
/// <summary>The backing raw tag's identity inputs for its RawPath + effective name.</summary>
/// <param name="Name">The raw tag's leaf name (also the default effective name).</param>
/// <param name="DeviceId">The tag's owning device id.</param>
/// <param name="TagGroupId">The tag's owning tag-group id, or <see langword="null"/> when directly under the device.</param>
public readonly record struct TagRow(string Name, string DeviceId, string? TagGroupId);
/// <summary>
/// Build the reference map. For each reference (ordered by <c>UnsTagReferenceId</c>), resolve the
/// backing tag's RawPath via <paramref name="resolver"/> and key it under the effective name within
/// the owning equipment. References whose backing tag is unknown or whose RawPath cannot be built
/// (broken chain) are skipped. Effective-name collisions within an equipment keep the first.
/// </summary>
/// <param name="references">The flattened UNS tag references (any order; sorted internally).</param>
/// <param name="tagsById">Backing raw tags keyed by <c>TagId</c>.</param>
/// <param name="resolver">The shared RawPath resolver over the raw topology.</param>
/// <returns><c>equipmentId → (effectiveName → RawPath)</c>. Equipments with no resolvable reference are absent.</returns>
public static IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> Build(
IEnumerable<ReferenceRow> references,
IReadOnlyDictionary<string, TagRow> tagsById,
RawPathResolver resolver)
{
ArgumentNullException.ThrowIfNull(references);
ArgumentNullException.ThrowIfNull(tagsById);
ArgumentNullException.ThrowIfNull(resolver);
var byEquip = new Dictionary<string, Dictionary<string, string>>(StringComparer.Ordinal);
foreach (var r in references.OrderBy(x => x.UnsTagReferenceId, StringComparer.Ordinal))
{
if (string.IsNullOrEmpty(r.EquipmentId) || string.IsNullOrEmpty(r.TagId)) continue;
if (!tagsById.TryGetValue(r.TagId, out var tag)) continue;
var effectiveName = string.IsNullOrEmpty(r.DisplayNameOverride) ? tag.Name : r.DisplayNameOverride!;
if (string.IsNullOrEmpty(effectiveName)) continue;
var rawPath = resolver.TryBuildTagPath(tag.DeviceId, tag.TagGroupId, tag.Name);
if (rawPath is null) continue; // broken chain — dropped (deploy gate rejects invalid names)
if (!byEquip.TryGetValue(r.EquipmentId, out var map))
byEquip[r.EquipmentId] = map = new Dictionary<string, string>(StringComparer.Ordinal);
map.TryAdd(effectiveName, rawPath); // first-wins on collision (validator rejects real collisions)
}
return byEquip.ToDictionary(
kv => kv.Key,
kv => (IReadOnlyDictionary<string, string>)kv.Value,
StringComparer.Ordinal);
}
/// <summary>The set of effective names a single equipment's references contribute (for the authoring
/// gate + Monaco completion, which need only the names — not the RawPaths).</summary>
/// <param name="references">The flattened UNS tag references for (typically) one equipment.</param>
/// <param name="tagNameById">Backing raw tag Name keyed by <c>TagId</c>.</param>
/// <returns>The distinct effective names, ordinal.</returns>
public static IReadOnlySet<string> EffectiveNames(
IEnumerable<ReferenceRow> references,
IReadOnlyDictionary<string, string> tagNameById)
{
ArgumentNullException.ThrowIfNull(references);
ArgumentNullException.ThrowIfNull(tagNameById);
var names = new HashSet<string>(StringComparer.Ordinal);
foreach (var r in references)
{
var effectiveName = r.DisplayNameOverride
?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null);
if (!string.IsNullOrEmpty(effectiveName)) names.Add(effectiveName);
}
return names;
}
}
@@ -3,19 +3,29 @@ using System.Text.RegularExpressions;
namespace ZB.MOM.WW.OtOpcUa.Commons.Types; namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
/// <summary> /// <summary>
/// Helpers for equipment-relative virtual-tag script paths. The reserved token /// Helpers for equipment-relative virtual-tag / scripted-alarm script paths. The reserved token
/// <c>{{equip}}</c> inside a <c>ctx.GetTag</c>/<c>ctx.SetVirtualTag</c> path literal is /// <c>{{equip}}/&lt;RefName&gt;</c> inside a <c>ctx.GetTag</c>/<c>ctx.SetVirtualTag</c> path literal is
/// replaced at the compose seams with the owning equipment's tag base prefix (derived /// replaced at the compose seams (<c>AddressSpaceComposer</c> + <c>DeploymentArtifact</c>) with the
/// from its child-tag <c>FullName</c>s). Pure + regex-based (no Roslyn) so the OpcUaServer /// backing raw tag's <c>RawPath</c>, resolved through the owning equipment's
/// composer and the Runtime artifact-decode path can both share it. Also the single home /// <c>UnsTagReference</c> rows by effective name (<c>&lt;RefName&gt;</c>).
/// for the <c>ctx.GetTag("…")</c> dependency-ref extraction those two seams used to /// <para><b>v3 syntax:</b> the token joint is a <b>slash</b> — <c>{{equip}}/&lt;RefName&gt;</c> — replacing
/// duplicate. /// the v2 dot-prefix derivation (<c>{{equip}}.X</c>). <c>&lt;RefName&gt;</c> is a reference's effective name
/// (its <c>DisplayNameOverride</c> else the backing raw tag's <c>Name</c>). An unresolved
/// <c>&lt;RefName&gt;</c> is left un-substituted (substitution never throws); the deploy-time validator +
/// the authoring gate + the Monaco diagnostic reject it first, preserving the invariant
/// <b>the editor accepts ⇔ publish accepts</b>.</para>
/// Pure + regex-based (no Roslyn) so the OpcUaServer composer and the Runtime artifact-decode path can
/// both share it. Also the single home for the <c>ctx.GetTag("…")</c> dependency-ref extraction those
/// two seams used to duplicate.
/// </summary> /// </summary>
public static class EquipmentScriptPaths public static class EquipmentScriptPaths
{ {
/// <summary>The reserved equipment-base token.</summary> /// <summary>The reserved equipment token stem.</summary>
public const string EquipToken = "{{equip}}"; public const string EquipToken = "{{equip}}";
/// <summary>The reserved equipment reference-relative prefix — the token stem plus the slash joint.</summary>
public const string EquipTokenPrefix = "{{equip}}/";
// ctx.GetTag("ref") — reads only; the dependency graph subscribes to exactly these. // ctx.GetTag("ref") — reads only; the dependency graph subscribes to exactly these.
private static readonly Regex GetTagRefRegex = private static readonly Regex GetTagRefRegex =
new(@"ctx\s*\.\s*GetTag\s*\(\s*""([^""]+)""\s*\)", RegexOptions.Compiled); new(@"ctx\s*\.\s*GetTag\s*\(\s*""([^""]+)""\s*\)", RegexOptions.Compiled);
@@ -32,6 +42,14 @@ public static class EquipmentScriptPaths
private static readonly Regex PathLiteralRegex = private static readonly Regex PathLiteralRegex =
new(@"(ctx\s*\.\s*(?:GetTag|SetVirtualTag)\s*\(\s*"")([^""]*)("")", RegexOptions.Compiled); new(@"(ctx\s*\.\s*(?:GetTag|SetVirtualTag)\s*\(\s*"")([^""]*)("")", RegexOptions.Compiled);
// {{equip}}/<RefName> occurrence in FREE TEXT (message templates). <RefName> runs until the next
// delimiter that cannot appear in an intra-literal RawPath segment used inline in a template —
// whitespace, quote, brace, paren, semicolon, or the '/' separator. (Reference effective names may
// contain internal spaces; that space-bearing form is unambiguous only inside a path literal, so the
// free-text scan is best-effort — see ExtractEquipReferenceNamesFromText.)
private static readonly Regex EquipRefTextRegex =
new(@"\{\{equip\}\}/([^\s""{}();/]+)", RegexOptions.Compiled);
// A pure pass-through virtual-tag body: exactly `return ctx.GetTag("<ref>").Value;` // A pure pass-through virtual-tag body: exactly `return ctx.GetTag("<ref>").Value;`
// (whitespace-insensitive). Captures <ref> for relay→alias conversion. Anything with extra // (whitespace-insensitive). Captures <ref> for relay→alias conversion. Anything with extra
// statements, arithmetic, a different member than .Value, or multiple GetTag calls is NOT a relay. // statements, arithmetic, a different member than .Value, or multiple GetTag calls is NOT a relay.
@@ -46,44 +64,89 @@ public static class EquipmentScriptPaths
!string.IsNullOrEmpty(source) && source.Contains(EquipToken, StringComparison.Ordinal); !string.IsNullOrEmpty(source) && source.Contains(EquipToken, StringComparison.Ordinal);
/// <summary> /// <summary>
/// Equipment tag base = the single shared substring-before-first-dot across the /// Replace each <c>{{equip}}/&lt;RefName&gt;</c> reference-relative path with the backing raw tag's
/// equipment's child-tag <c>FullName</c>s. Returns <c>null</c> when there are no usable /// <c>RawPath</c> from <paramref name="referenceMap"/> (effective name → RawPath), inside
/// FullNames or they don't agree on one prefix (equipment spanning multiple objects). /// <c>ctx.GetTag</c>/<c>ctx.SetVirtualTag</c> path literals only. A path literal whose content is
/// <c>{{equip}}/&lt;RefName&gt;</c> is treated as a single reference: <c>&lt;RefName&gt;</c> is the
/// entire remainder after the prefix (so reference effective names may contain internal spaces).
/// Identity when <paramref name="source"/> is null/empty, <paramref name="referenceMap"/> is empty,
/// or the token is absent (so every existing script — none of which use the token — is byte-unchanged).
/// An <c>&lt;RefName&gt;</c> absent from the map is left un-substituted (never throws) — the validator
/// rejects it first at deploy.
/// </summary> /// </summary>
/// <param name="childFullNames">The equipment's child-tag driver FullNames.</param> /// <param name="source">The script source.</param>
/// <returns>The shared base prefix, or null when none/ambiguous.</returns> /// <param name="referenceMap">The equipment's reference map: effective name → backing-tag RawPath.</param>
public static string? DeriveEquipmentBase(IEnumerable<string?> childFullNames) /// <returns>The source with each resolvable <c>{{equip}}/&lt;RefName&gt;</c> substituted inside path literals.</returns>
public static string SubstituteEquipmentToken(string source, IReadOnlyDictionary<string, string> referenceMap)
{ {
string? found = null; if (string.IsNullOrEmpty(source) || referenceMap is null || referenceMap.Count == 0) return source;
foreach (var fn in childFullNames) if (!source.Contains(EquipTokenPrefix, StringComparison.Ordinal)) return source;
{ return PathLiteralRegex.Replace(source, m =>
if (string.IsNullOrWhiteSpace(fn)) continue; m.Groups[1].Value
var dot = fn.IndexOf('.'); + SubstituteContent(m.Groups[2].Value, referenceMap)
var prefix = dot < 0 ? fn : fn.Substring(0, dot); + m.Groups[3].Value);
if (prefix.Length == 0) continue; }
if (found is null) found = prefix;
else if (!string.Equals(found, prefix, StringComparison.Ordinal)) return null; // Substitute the reference-relative token inside a single path-literal's content. The content is one
} // whole tag path; when it starts with the {{equip}}/ prefix the remainder is the reference name.
return found; private static string SubstituteContent(string content, IReadOnlyDictionary<string, string> referenceMap)
{
if (!content.StartsWith(EquipTokenPrefix, StringComparison.Ordinal)) return content;
var refName = content.Substring(EquipTokenPrefix.Length);
if (refName.Length == 0) return content;
return referenceMap.TryGetValue(refName, out var rawPath) ? rawPath : content;
} }
/// <summary> /// <summary>
/// Replace <c>{{equip}}</c> with <paramref name="equipBase"/> inside /// Distinct <c>&lt;RefName&gt;</c> values used via <c>{{equip}}/&lt;RefName&gt;</c> inside
/// <c>ctx.GetTag</c>/<c>ctx.SetVirtualTag</c> path literals only. Identity when /// <c>ctx.GetTag</c>/<c>ctx.SetVirtualTag</c> path literals, in first-seen order. Scoped to the SAME
/// <paramref name="equipBase"/> is null/empty or the token is absent (so every existing /// path literals <see cref="SubstituteEquipmentToken"/> operates on (so a token in a comment / logger
/// script — none of which use the token — is byte-unchanged). /// string is not reported), and the entire post-prefix remainder is the reference name (matching
/// substitution) — this keeps the validator / authoring gate / Monaco diagnostic in lockstep with what
/// resolves. Feeds the deploy-gate + authoring rejection + editor completion.
/// </summary> /// </summary>
/// <param name="source">The script source.</param> /// <param name="scriptSource">The virtual-tag / scripted-alarm predicate script source.</param>
/// <param name="equipBase">The equipment base prefix, or null/empty for no substitution.</param> /// <returns>Distinct reference names, first-seen order; empty when none / no token.</returns>
/// <returns>The source with the token substituted inside path literals.</returns> public static IReadOnlyList<string> ExtractEquipReferenceNames(string? scriptSource)
public static string SubstituteEquipmentToken(string source, string? equipBase)
{ {
if (string.IsNullOrEmpty(source) || string.IsNullOrEmpty(equipBase)) return source; if (string.IsNullOrEmpty(scriptSource)
if (!source.Contains(EquipToken, StringComparison.Ordinal)) return source; || !scriptSource.Contains(EquipTokenPrefix, StringComparison.Ordinal))
return PathLiteralRegex.Replace(source, m => return Array.Empty<string>();
m.Groups[1].Value var seen = new HashSet<string>(StringComparer.Ordinal);
+ m.Groups[2].Value.Replace(EquipToken, equipBase, StringComparison.Ordinal) var result = new List<string>();
+ m.Groups[3].Value); foreach (Match m in PathLiteralRegex.Matches(scriptSource))
{
var content = m.Groups[2].Value;
if (!content.StartsWith(EquipTokenPrefix, StringComparison.Ordinal)) continue;
var refName = content.Substring(EquipTokenPrefix.Length);
if (refName.Length > 0 && seen.Add(refName)) result.Add(refName);
}
return result;
}
/// <summary>
/// Distinct <c>&lt;RefName&gt;</c> values used via <c>{{equip}}/&lt;RefName&gt;</c> in FREE TEXT — the
/// scripted-alarm <c>MessageTemplate</c>, which is not a path literal — in first-seen order. Best-effort:
/// a reference name is captured up to the next whitespace / quote / brace / paren / semicolon / slash, so
/// a space-bearing effective name used inline in a template resolves only up to its first space (a
/// documented, benign limitation — template rendering is dark until Batch 4). Feeds the deploy-gate rule
/// for alarm message tokens.
/// </summary>
/// <param name="text">The free text (e.g. a scripted-alarm message template).</param>
/// <returns>Distinct reference names, first-seen order; empty when none / no token.</returns>
public static IReadOnlyList<string> ExtractEquipReferenceNamesFromText(string? text)
{
if (string.IsNullOrEmpty(text)
|| !text.Contains(EquipTokenPrefix, StringComparison.Ordinal))
return Array.Empty<string>();
var seen = new HashSet<string>(StringComparer.Ordinal);
var result = new List<string>();
foreach (Match m in EquipRefTextRegex.Matches(text))
{
var refName = m.Groups[1].Value;
if (refName.Length > 0 && seen.Add(refName)) result.Add(refName);
}
return result;
} }
/// <summary> /// <summary>
@@ -115,10 +178,11 @@ public static class EquipmentScriptPaths
/// <c>{{equip}}</c> double-brace form is excluded by the token regex. Deterministic so the live /// <c>{{equip}}</c> double-brace form is excluded by the token regex. Deterministic so the live
/// composer (<c>AddressSpaceComposer</c>) and the artifact-decode mirror (<c>DeploymentArtifact</c>) /// composer (<c>AddressSpaceComposer</c>) and the artifact-decode mirror (<c>DeploymentArtifact</c>)
/// produce the exact same ordered list — the byte-parity contract <c>EquipmentScriptedAlarmPlan</c> /// produce the exact same ordered list — the byte-parity contract <c>EquipmentScriptedAlarmPlan</c>
/// equality depends on. Scripted alarms do NOT use <c>{{equip}}</c> substitution (only virtual /// equality depends on. The predicate source passed here is the ALREADY-substituted source (the two
/// tags do) — pass the predicate source as-is. /// compose seams substitute <c>{{equip}}/&lt;RefName&gt;</c> before extraction, identically), so the
/// merged refs are resolved RawPaths.
/// </summary> /// </summary>
/// <param name="predicateSource">The resolved predicate script source.</param> /// <param name="predicateSource">The resolved (substituted) predicate script source.</param>
/// <param name="messageTemplate">The alarm message template carrying <c>{TagPath}</c> tokens.</param> /// <param name="messageTemplate">The alarm message template carrying <c>{TagPath}</c> tokens.</param>
/// <returns>The merged, distinct, deterministically-ordered dependency refs.</returns> /// <returns>The merged, distinct, deterministically-ordered dependency refs.</returns>
public static IReadOnlyList<string> ExtractAlarmDependencyRefs(string? predicateSource, string? messageTemplate) public static IReadOnlyList<string> ExtractAlarmDependencyRefs(string? predicateSource, string? messageTemplate)
@@ -154,7 +218,7 @@ public static class EquipmentScriptPaths
/// <param name="source">The virtual-tag script source to inspect.</param> /// <param name="source">The virtual-tag script source to inspect.</param>
/// <param name="tagReference"> /// <param name="tagReference">
/// When the method returns <see langword="true"/>, the captured <c>GetTag</c> path literal /// When the method returns <see langword="true"/>, the captured <c>GetTag</c> path literal
/// (e.g. <c>TestMachine_020.TestChangingInt</c> or <c>{{equip}}.Speed</c>); /// (e.g. <c>TestMachine_020.TestChangingInt</c> or <c>{{equip}}/Speed</c>);
/// otherwise <see langword="null"/>. /// otherwise <see langword="null"/>.
/// </param> /// </param>
/// <returns> /// <returns>
@@ -39,6 +39,7 @@ public static class DraftValidator
ValidateRawNameCharset(draft, errors); ValidateRawNameCharset(draft, errors);
ValidateHistorizedTagnameLength(draft, errors); ValidateHistorizedTagnameLength(draft, errors);
ValidateUnsEffectiveLeafUniqueness(draft, errors); ValidateUnsEffectiveLeafUniqueness(draft, errors);
ValidateEquipReferenceResolution(draft, errors);
ValidateCalculationTags(draft, errors); ValidateCalculationTags(draft, errors);
return errors; return errors;
} }
@@ -238,6 +239,64 @@ public static class DraftValidator
} }
} }
/// <summary>v3: every <c>{{equip}}/&lt;RefName&gt;</c> used in an equipment's VirtualTag script,
/// ScriptedAlarm predicate script, or ScriptedAlarm message template must resolve to one of the owning
/// equipment's <c>UnsTagReference</c> effective names (<c>DisplayNameOverride</c> else the backing raw
/// tag's <c>Name</c>) — the same set the compose seams substitute against. An unresolved <c>&lt;RefName&gt;</c>
/// is a deploy error (replacing the v2 silent null-base no-substitution), naming the script/alarm, the
/// equipment, and the missing ref. This is the deploy-gate half of the invariant the Monaco diagnostic +
/// the authoring gate enforce (editor accepts ⇔ publish accepts).</summary>
private static void ValidateEquipReferenceResolution(DraftSnapshot draft, List<ValidationError> errors)
{
var tagNameById = draft.Tags.GroupBy(t => t.TagId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().Name, StringComparer.Ordinal);
// equipmentId → set of reference effective names ({{equip}}/<RefName> resolves through REFERENCES
// only — VirtualTags/ScriptedAlarms are computed signals with no backing RawPath).
var refNamesByEquip = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal);
foreach (var r in draft.UnsTagReferences)
{
var effective = r.DisplayNameOverride ?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null);
if (string.IsNullOrEmpty(effective)) continue;
if (!refNamesByEquip.TryGetValue(r.EquipmentId, out var set))
refNamesByEquip[r.EquipmentId] = set = new HashSet<string>(StringComparer.Ordinal);
set.Add(effective!);
}
var scriptSourceById = draft.Scripts.GroupBy(s => s.ScriptId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().SourceCode, StringComparer.Ordinal);
void CheckRefs(string equipmentId, IEnumerable<string> refNames, string source)
{
var have = refNamesByEquip.TryGetValue(equipmentId, out var set) ? set : null;
foreach (var name in refNames)
{
if (have is not null && have.Contains(name)) continue;
errors.Add(new("EquipReferenceUnresolved",
$"{source} uses '{EquipmentScriptPaths.EquipTokenPrefix}{name}' but equipment '{equipmentId}' has " +
$"no reference named '{name}'; add a reference with that effective name or correct the script.",
equipmentId));
}
}
foreach (var v in draft.VirtualTags)
{
var src = scriptSourceById.TryGetValue(v.ScriptId, out var s) ? s : null;
var used = EquipmentScriptPaths.ExtractEquipReferenceNames(src);
if (used.Count > 0) CheckRefs(v.EquipmentId, used, $"VirtualTag '{v.VirtualTagId}'");
}
foreach (var a in draft.ScriptedAlarms)
{
var src = scriptSourceById.TryGetValue(a.PredicateScriptId, out var s) ? s : null;
var used = EquipmentScriptPaths.ExtractEquipReferenceNames(src)
.Concat(EquipmentScriptPaths.ExtractEquipReferenceNamesFromText(a.MessageTemplate))
.Distinct(StringComparer.Ordinal)
.ToList();
if (used.Count > 0) CheckRefs(a.EquipmentId, used, $"ScriptedAlarm '{a.ScriptedAlarmId}'");
}
}
private static bool IsValidSegment(string? s) => private static bool IsValidSegment(string? s) =>
s is not null && (UnsSegment.IsMatch(s) || s == UnsDefaultSegment); s is not null && (UnsSegment.IsMatch(s) || s == UnsDefaultSegment);
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.MxGateway.Client; using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing; using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
@@ -34,11 +35,14 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
}; };
private readonly ILogger<GalaxyDriverBrowser> _logger; private readonly ILogger<GalaxyDriverBrowser> _logger;
private readonly ISecretResolver _secretResolver;
/// <summary>Creates a new browser. Logger defaults to <see cref="NullLogger{T}"/>.</summary> /// <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> /// <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; _logger = logger ?? NullLogger<GalaxyDriverBrowser>.Instance;
} }
@@ -68,7 +72,7 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
if (string.IsNullOrWhiteSpace(opts.MxAccess.ClientName)) if (string.IsNullOrWhiteSpace(opts.MxAccess.ClientName))
throw new InvalidOperationException("Galaxy browser requires 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 // 30s wall-clock budget for the connect phase, linked to the caller's token so
// an AdminUI cancel still wins early. // 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 /// 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 /// 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 /// 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 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> /// </summary>
private MxGatewayClientOptions BuildClientOptions(GalaxyGatewayOptions gw) => new() private async Task<MxGatewayClientOptions> BuildClientOptionsAsync(
GalaxyGatewayOptions gw, CancellationToken cancellationToken)
{ {
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute), var apiKey = await GalaxySecretRef
ApiKey = GalaxySecretRef.ResolveApiKey(gw.ApiKeySecretRef, _logger), .ResolveApiKeyAsync(gw.ApiKeySecretRef, _secretResolver, _logger, cancellationToken)
UseTls = gw.UseTls, .ConfigureAwait(false);
CaCertificatePath = gw.CaCertificatePath, return new MxGatewayClientOptions
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds), {
DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds), Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
StreamTimeout = gw.StreamTimeoutSeconds > 0 ApiKey = apiKey,
? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds) UseTls = gw.UseTls,
: null, 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> /// <summary>
/// Connection details for the MxAccess gateway. <see cref="ApiKeySecretRef"/> is /// 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: /// supported, in priority order:
/// <list type="bullet"> /// <list type="bullet">
/// <item><c>env:NAME</c> — read from an environment variable (recommended for /// <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> /// 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>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; /// <item><c>dev:KEY</c> — explicit cleartext opt-in for dev rigs / parity tests;
/// no startup warning.</item> /// no startup warning.</item>
/// <item>Anything else — treated as a literal cleartext API key for back-compat. /// <item>Anything else — treated as a literal cleartext API key for back-compat.
@@ -1,9 +1,10 @@
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
/// <summary> /// <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: /// forms supported, evaluated in order:
/// <list type="number"> /// <list type="number">
/// <item><c>env:NAME</c> — reads <c>Environment.GetEnvironmentVariable(NAME)</c>. /// <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 /// <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 /// 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> /// 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 /// <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 /// configs that pre-date this resolver. When a logger is supplied the
/// resolver emits a startup warning so an operator who accidentally /// resolver emits a startup warning so an operator who accidentally
/// committed a cleartext key sees it.</item> /// committed a cleartext key sees it.</item>
/// </list> /// </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. /// changing the call site.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
@@ -31,18 +37,26 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
public static class GalaxySecretRef public static class GalaxySecretRef
{ {
/// <summary> /// <summary>
/// Resolves the supplied secret reference. When the ref falls through to the /// Resolves the supplied secret reference. The <c>secret:NAME</c> arm resolves
/// back-compat literal arm (an unprefixed cleartext API key in /// through <paramref name="resolver"/> and is fail-closed (throws when the secret
/// <c>DriverConfig</c> JSON) and a <paramref name="logger"/> is supplied, emits /// is absent). When the ref falls through to the back-compat literal arm (an
/// a <see cref="LogLevel.Warning"/>. The <c>dev:</c> prefix is the explicit /// unprefixed cleartext API key in <c>DriverConfig</c> JSON) and a
/// opt-in path that doesn't warn. /// <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> /// </summary>
/// <param name="secretRef">The secret reference string to resolve.</param> /// <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="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> /// <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); ArgumentException.ThrowIfNullOrEmpty(secretRef);
ArgumentNullException.ThrowIfNull(resolver);
if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase)) if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
{ {
@@ -76,6 +90,19 @@ public static class GalaxySecretRef
return secretRef[4..]; 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 // 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 // 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 // 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" /> <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. --> <!-- Logging abstraction needed by GalaxySecretRef.ResolveApiKey's optional warning logger. -->
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions" />
</ItemGroup> </ItemGroup>
</Project> </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.Config;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Health; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Health;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
@@ -43,6 +44,12 @@ public sealed class GalaxyDriver
private readonly GalaxyDriverOptions _options; private readonly GalaxyDriverOptions _options;
private readonly ILogger<GalaxyDriver> _logger; 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 // PR 4.1 — IGalaxyHierarchySource is the test seam for browse. When null, the driver
// lazily builds a GatewayGalaxyHierarchySource around a GalaxyRepositoryClient on // lazily builds a GatewayGalaxyHierarchySource around a GalaxyRepositoryClient on
// first DiscoverAsync. Tests inject a fake source via the internal ctor to exercise // 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> /// <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="driverInstanceId">The unique identifier for this driver instance.</param>
/// <param name="options">The Galaxy driver configuration options.</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> /// <param name="logger">Optional logger instance for diagnostics.</param>
public GalaxyDriver( public GalaxyDriver(
string driverInstanceId, string driverInstanceId,
GalaxyDriverOptions options, GalaxyDriverOptions options,
ISecretResolver secretResolver,
ILogger<GalaxyDriver>? logger = null) ILogger<GalaxyDriver>? logger = null)
: this(driverInstanceId, options, : this(driverInstanceId, options,
hierarchySource: null, dataReader: null, dataWriter: null, subscriber: null, 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="alarmAcknowledger">Optional custom alarm acknowledger for testing.</param>
/// <param name="alarmFeed">Optional custom alarm feed for testing.</param> /// <param name="alarmFeed">Optional custom alarm feed for testing.</param>
/// <param name="logger">Optional logger instance for diagnostics.</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( internal GalaxyDriver(
string driverInstanceId, string driverInstanceId,
GalaxyDriverOptions options, GalaxyDriverOptions options,
@@ -182,13 +197,15 @@ public sealed class GalaxyDriver
IGalaxySubscriber? subscriber = null, IGalaxySubscriber? subscriber = null,
IGalaxyAlarmAcknowledger? alarmAcknowledger = null, IGalaxyAlarmAcknowledger? alarmAcknowledger = null,
IGalaxyAlarmFeed? alarmFeed = null, IGalaxyAlarmFeed? alarmFeed = null,
ILogger<GalaxyDriver>? logger = null) ILogger<GalaxyDriver>? logger = null,
ISecretResolver? secretResolver = null)
{ {
_driverInstanceId = !string.IsNullOrWhiteSpace(driverInstanceId) _driverInstanceId = !string.IsNullOrWhiteSpace(driverInstanceId)
? driverInstanceId ? driverInstanceId
: throw new ArgumentException("Driver instance id required.", nameof(driverInstanceId)); : throw new ArgumentException("Driver instance id required.", nameof(driverInstanceId));
_options = options ?? throw new ArgumentNullException(nameof(options)); _options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? NullLogger<GalaxyDriver>.Instance; _logger = logger ?? NullLogger<GalaxyDriver>.Instance;
_secretResolver = secretResolver ?? NullSecretResolver.Instance;
_hierarchySource = hierarchySource; _hierarchySource = hierarchySource;
_dataReader = dataReader; _dataReader = dataReader;
_dataWriter = dataWriter; _dataWriter = dataWriter;
@@ -316,7 +333,7 @@ public sealed class GalaxyDriver
_driverInstanceId); _driverInstanceId);
} }
StartDeployWatcher(); await StartDeployWatcherAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation( _logger.LogInformation(
"GalaxyDriver {InstanceId} initialized — endpoint={Endpoint} clientName={ClientName}", "GalaxyDriver {InstanceId} initialized — endpoint={Endpoint} clientName={ClientName}",
_driverInstanceId, _options.Gateway.Endpoint, _options.MxAccess.ClientName); _driverInstanceId, _options.Gateway.Endpoint, _options.MxAccess.ClientName);
@@ -331,7 +348,7 @@ public sealed class GalaxyDriver
/// </summary> /// </summary>
private async Task BuildProductionRuntimeAsync(CancellationToken cancellationToken) private async Task BuildProductionRuntimeAsync(CancellationToken cancellationToken)
{ {
var clientOptions = BuildClientOptions(_options.Gateway); var clientOptions = await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false);
_ownedMxClient = MxGatewayClient.Create(clientOptions); _ownedMxClient = MxGatewayClient.Create(clientOptions);
_ownedMxSession = new GalaxyMxSession(_options.MxAccess, _logger); _ownedMxSession = new GalaxyMxSession(_options.MxAccess, _logger);
await _ownedMxSession.ConnectAsync(clientOptions, cancellationToken).ConfigureAwait(false); await _ownedMxSession.ConnectAsync(clientOptions, cancellationToken).ConfigureAwait(false);
@@ -386,7 +403,7 @@ public sealed class GalaxyDriver
private async Task ReopenAsync(CancellationToken cancellationToken) private async Task ReopenAsync(CancellationToken cancellationToken)
{ {
if (_ownedMxSession is null) return; 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); await _ownedMxSession.RecreateAsync(clientOptions, cancellationToken).ConfigureAwait(false);
// The recreated session invalidates every prior gw item handle; drop the writer's handle/advise // 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. // 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), // Resolve the API-key ref BEFORE the object initializer — the secret: arm is
// Pass the logger so the literal-arm cleartext fallback surfaces a startup // async and you can't await inside an initializer. Pass the logger so the
// warning rather than silently shipping the key. The resolver lives in // literal-arm cleartext fallback surfaces a startup warning rather than
// Driver.Galaxy.Contracts (GalaxySecretRef) so the runtime driver and the // silently shipping the key. The resolver lives in Driver.Galaxy.Contracts
// AdminUI browser share one implementation. // (GalaxySecretRef) so the runtime driver and the AdminUI browser share one
ApiKey = GalaxySecretRef.ResolveApiKey(gw.ApiKeySecretRef, _logger), // implementation; the secret: arm resolves through the shared ISecretResolver.
UseTls = gw.UseTls, var apiKey = await GalaxySecretRef
CaCertificatePath = gw.CaCertificatePath, .ResolveApiKeyAsync(gw.ApiKeySecretRef, _secretResolver, _logger, cancellationToken)
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds), .ConfigureAwait(false);
DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds), return new MxGatewayClientOptions
StreamTimeout = gw.StreamTimeoutSeconds > 0 ? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds) : null, {
}; 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 (!_options.Repository.WatchDeployEvents) return;
if (_ownedRepositoryClient is null && _hierarchySource is null) return; if (_ownedRepositoryClient is null && _hierarchySource is null) return;
// Reuse the lazily-built repository client (DiscoverAsync constructs it on demand). // 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. // 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 // Build under a null-check (not ??=) so if BuildDefaultHierarchySourceAsync later
// rather than overwriting the field and leaking the first instance. // 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( _ownedRepositoryClient ??= ZB.MOM.WW.MxGateway.Client.GalaxyRepositoryClient.Create(
BuildClientOptions(_options.Gateway)); await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false));
var source = new GatewayGalaxyDeployWatchSource(_ownedRepositoryClient); var source = new GatewayGalaxyDeployWatchSource(_ownedRepositoryClient);
_deployWatcher = new DeployWatcher(source, _logger); _deployWatcher = new DeployWatcher(source, _logger);
@@ -691,7 +718,8 @@ public sealed class GalaxyDriver
// After discovery, SyncPlatformsAsync refreshes the probe watcher's membership so // After discovery, SyncPlatformsAsync refreshes the probe watcher's membership so
// newly added $WinPlatform / $AppEngine objects start advising their ScanState attribute. // newly added $WinPlatform / $AppEngine objects start advising their ScanState attribute.
var capturingBuilder = new SecurityCapturingBuilder(builder, _securityByFullRef); 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 // 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 // its node reference (FullName) — the security map SecurityCapturingBuilder captures is then
// keyed by RawPath, matching what WriteAsync resolves against. // 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 /// <see cref="Dispose"/>. Tests bypass this by injecting their own source via the
/// internal ctor. /// internal ctor.
/// </summary> /// </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 // than always overwriting the field and leaking the first instance. Both paths
// produce equivalent clients from the same options. // produce equivalent clients from the same options. The client-options build is
_ownedRepositoryClient ??= GalaxyRepositoryClient.Create(BuildClientOptions(_options.Gateway)); // async now (secret: arm resolves through the shared ISecretResolver).
_ownedRepositoryClient ??= GalaxyRepositoryClient.Create(
await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false));
return new TracedGalaxyHierarchySource( return new TracedGalaxyHierarchySource(
new GatewayGalaxyHierarchySource(_ownedRepositoryClient), _options.MxAccess.ClientName); 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.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
@@ -23,30 +24,45 @@ public static class GalaxyDriverFactoryExtensions
{ {
public const string DriverTypeName = "GalaxyMxGateway"; 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="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> /// <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); 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="driverInstanceId">The unique identifier for the driver instance.</param>
/// <param name="driverConfigJson">The driver configuration in JSON format.</param> /// <param name="driverConfigJson">The driver configuration in JSON format.</param>
/// <returns>The constructed Galaxy driver instance.</returns> /// <returns>The constructed Galaxy driver instance.</returns>
public static GalaxyDriver CreateInstance(string driverInstanceId, string driverConfigJson) 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="driverInstanceId">The unique identifier for the driver instance.</param>
/// <param name="driverConfigJson">The driver configuration in JSON format.</param> /// <param name="driverConfigJson">The driver configuration in JSON format.</param>
/// <param name="loggerFactory">The optional logger factory for creating drivers.</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> /// <returns>The constructed Galaxy driver instance.</returns>
public static GalaxyDriver CreateInstance( 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(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson); ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
@@ -88,7 +104,7 @@ public static class GalaxyDriverFactoryExtensions
RawTags = dto.RawTags ?? [], 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() 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 /// exposes PLC data" flow. Tier A (pure managed, OPC Foundation reference SDK); universal
/// protections cover it. /// protections cover it.
/// </remarks> /// </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> /// <summary>
/// Remote OPC UA endpoint URL, e.g. <c>opc.tcp://plc.internal:4840</c>. Convenience /// 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.Client;
using Opc.Ua.Configuration; using Opc.Ua.Configuration;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; 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="options">Driver configuration.</param>
/// <param name="driverInstanceId">Stable logical ID from the config DB.</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="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, public OpcUaClientDriver(OpcUaClientDriverOptions options, string driverInstanceId,
ILogger<OpcUaClientDriver>? logger = null) ILogger<OpcUaClientDriver>? logger = null, ISecretResolver? secretResolver = null)
{ {
_options = options; _options = options;
_driverInstanceId = driverInstanceId; _driverInstanceId = driverInstanceId;
_logger = logger ?? NullLogger<OpcUaClientDriver>.Instance; _logger = logger ?? NullLogger<OpcUaClientDriver>.Instance;
_secretResolver = secretResolver ?? NullSecretResolver.Instance;
} }
private readonly OpcUaClientDriverOptions _options; private readonly OpcUaClientDriverOptions _options;
private readonly ISecretResolver _secretResolver;
private readonly string _driverInstanceId; private readonly string _driverInstanceId;
// ---- IAlarmSource state ---- // ---- IAlarmSource state ----
@@ -159,7 +170,18 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
var appConfig = await BuildApplicationConfigurationAsync(cancellationToken).ConfigureAwait(false); var appConfig = await BuildApplicationConfigurationAsync(cancellationToken).ConfigureAwait(false);
var candidates = ResolveEndpointCandidates(_options); 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 // 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 // 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 System.Text.Json.Serialization;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
@@ -31,22 +32,37 @@ public static class OpcUaClientDriverFactoryExtensions
Converters = { new JsonStringEnumConverter() }, 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="registry">The driver factory registry to register with.</param>
/// <param name="loggerFactory">Optional logger factory used to create per-instance loggers.</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); 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> /// <summary>Public for the Server-side bootstrapper + test consumers.</summary>
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param> /// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
/// <param name="driverConfigJson">The JSON configuration string for the driver.</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="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> /// <returns>A configured <see cref="OpcUaClientDriver"/>.</returns>
public static OpcUaClientDriver CreateInstance( 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(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson); ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
@@ -55,6 +71,8 @@ public static class OpcUaClientDriverFactoryExtensions
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
$"OpcUaClient driver config for '{driverInstanceId}' deserialised to null"); $"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 /> /// <inheritdoc />
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) 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; OpcUaClientDriverOptions? opts;
try { opts = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(configJson, _opts); } try { opts = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(configJson, _opts); }
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); } 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> <ItemGroup>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client"/> <PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client"/>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration"/> <PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration"/>
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -19,7 +19,7 @@
<HeadOutlet/> <HeadOutlet/>
</head> </head>
<body> <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> <script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
<ThemeScripts /> <ThemeScripts />
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/js/monaco-init.js"></script> <script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/js/monaco-init.js"></script>
@@ -20,6 +20,7 @@
<NavRailItem Href="/reservations" Text="Reservations" /> <NavRailItem Href="/reservations" Text="Reservations" />
<NavRailItem Href="/certificates" Text="Certificates" /> <NavRailItem Href="/certificates" Text="Certificates" />
<NavRailItem Href="/role-grants" Text="Role grants" /> <NavRailItem Href="/role-grants" Text="Role grants" />
<NavRailItem Href="/admin/secrets" Text="Secrets" />
</NavRailSection> </NavRailSection>
<NavRailSection Title="Scripting" Key="scripting"> <NavRailSection Title="Scripting" Key="scripting">
<NavRailItem Href="/scripts" Text="Scripts" /> <NavRailItem Href="/scripts" Text="Scripts" />
@@ -64,7 +64,22 @@ else
<tr> <tr>
<td><span class="mono small">@e.TimestampUtc.ToString("HH:mm:ss.fff")</span></td> <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">@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><span class="chip @KindChipClass(e.TransitionKind)">@e.TransitionKind</span></td>
<td class="num">@e.Severity</td> <td class="num">@e.Severity</td>
<td>@e.User</td> <td>@e.User</td>
@@ -78,17 +78,9 @@ else
</InputSelect> </InputSelect>
<ValidationMessage For="@(() => _form.UnsLineId)" /> <ValidationMessage For="@(() => _form.UnsLineId)" />
</div> </div>
<div class="col-md-6 mb-3">
<label class="form-label" for="eq-driver">Driver instance</label>
<InputSelect id="eq-driver" @bind-Value="_form.DriverInstanceId" class="form-select form-select-sm">
<option value="">(none / driver-less)</option>
@foreach (var (id, display) in _driverOptions)
{
<option value="@id">@display</option>
}
</InputSelect>
</div>
</div> </div>
@* v3: equipment no longer binds a driver — device I/O is authored in the /raw tree and surfaced
here by reference on the Tags tab. The old "Driver instance" select was removed. *@
<div class="row"> <div class="row">
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<label class="form-label" for="eq-ztag">ZTag (ERP)</label> <label class="form-label" for="eq-ztag">ZTag (ERP)</label>
@@ -138,38 +130,48 @@ else
} }
else if (_activeTab == "tags") else if (_activeTab == "tags")
{ {
@* v3 Batch 3: equipment is reference-only — the Tags tab lists UnsTagReference rows pointing at
raw tags. Datatype/access are inherited (read-only) from the backing raw tag; the display-name
override is the only per-reference editable field. *@
<div class="d-flex justify-content-end align-items-center gap-2 mb-2"> <div class="d-flex justify-content-end align-items-center gap-2 mb-2">
<button type="button" class="btn btn-outline-primary btn-sm" @onclick="OpenAddTag">Add tag</button> <button type="button" class="btn btn-outline-primary btn-sm" @onclick="OpenAddReference">+ Add reference</button>
</div> </div>
@if (!string.IsNullOrWhiteSpace(_tagError)) @if (!string.IsNullOrWhiteSpace(_refError))
{ {
<div class="text-danger small mb-2">@_tagError</div> <div class="text-danger small mb-2">@_refError</div>
} }
@if (_tags is null) @if (_refs is null)
{ {
<p class="text-muted"><span class="spinner-border spinner-border-sm me-1"></span>Loading…</p> <p class="text-muted"><span class="spinner-border spinner-border-sm me-1"></span>Loading…</p>
} }
else if (_tags.Count == 0) else if (_refs.Count == 0)
{ {
<p class="text-muted">No tags yet.</p> <p class="text-muted">No tag references yet.</p>
} }
else else
{ {
<table class="table table-sm"> <table class="table table-sm align-middle">
<thead> <thead>
<tr><th>Name</th><th>Driver</th><th>Data type</th><th>Access</th><th class="text-end">Actions</th></tr> <tr>
<th>Effective name</th><th>Raw path</th><th>Data type</th><th>Access</th>
<th>Display-name override</th><th class="text-end">Actions</th>
</tr>
</thead> </thead>
<tbody> <tbody>
@foreach (var t in _tags) @foreach (var r in _refs)
{ {
<tr @key="t.TagId"> <tr @key="r.UnsTagReferenceId">
<td>@t.Name</td> <td>@r.EffectiveName</td>
<td class="mono">@t.DriverInstanceId</td> <td class="mono small">@r.RawPath</td>
<td>@t.DataType</td> <td>@r.DataType</td>
<td>@t.AccessLevel</td> <td>@r.AccessLevel</td>
<td class="text-end"> <td>
<button type="button" class="btn btn-outline-secondary btn-sm me-1" @onclick="() => OpenEditTag(t.TagId)">Edit</button> <input class="form-control form-control-sm" @bind="_overrideEdits[r.UnsTagReferenceId]"
<button type="button" class="btn btn-outline-danger btn-sm" @onclick="() => DeleteTag(t.TagId)">Delete</button> placeholder="(uses raw name)" />
</td>
<td class="text-end text-nowrap">
<button type="button" class="btn btn-outline-secondary btn-sm me-1" @onclick="() => SaveOverride(r)">Save name</button>
<button type="button" class="btn btn-outline-danger btn-sm" @onclick="() => RemoveReference(r)">Remove</button>
</td> </td>
</tr> </tr>
} }
@@ -177,9 +179,8 @@ else
</table> </table>
} }
<TagModal Visible="_tagModalVisible" IsNew="_tagModalIsNew" EquipmentId="@EquipmentId" <AddReferenceModal Visible="_refModalVisible" EquipmentId="@EquipmentId"
Existing="_tagModalExisting" Drivers="_tagDriverOptions" OnCommitted="OnReferencesCommittedAsync" OnCancel="@(() => { _refModalVisible = false; })" />
OnSaved="OnTagSavedAsync" OnCancel="@(() => { _tagModalVisible = false; })" />
} }
else if (_activeTab == "vtags") else if (_activeTab == "vtags")
{ {
@@ -290,15 +291,13 @@ else
private EquipmentEditDto? _equipment; private EquipmentEditDto? _equipment;
private FormModel _form = new(); private FormModel _form = new();
private IReadOnlyList<(string Id, string Display)> _lineOptions = Array.Empty<(string, string)>(); private IReadOnlyList<(string Id, string Display)> _lineOptions = Array.Empty<(string, string)>();
private IReadOnlyList<(string Id, string Display)> _driverOptions = Array.Empty<(string, string)>();
// --- Tags tab state. _tags is null until the tab is first activated (drives the lazy load + spinner). --- // --- Tags tab state (v3 reference-only). _refs is null until the tab is first activated (lazy load +
private IReadOnlyList<EquipmentTagRow>? _tags; // spinner). _overrideEdits carries the per-row editable display-name override, keyed by reference id. ---
private string? _tagError; private IReadOnlyList<EquipmentReferenceRow>? _refs;
private bool _tagModalVisible; private readonly Dictionary<string, string?> _overrideEdits = new(StringComparer.Ordinal);
private bool _tagModalIsNew; private string? _refError;
private TagEditDto? _tagModalExisting; private bool _refModalVisible;
private IReadOnlyList<(string Id, string Display, string DriverType, string DriverConfig)> _tagDriverOptions = Array.Empty<(string, string, string, string)>();
// --- Virtual Tags tab state. _vtags is null until the tab is first activated. --- // --- Virtual Tags tab state. _vtags is null until the tab is first activated. ---
private IReadOnlyList<EquipmentVirtualTagRow>? _vtags; private IReadOnlyList<EquipmentVirtualTagRow>? _vtags;
@@ -329,54 +328,48 @@ else
{ {
_activeTab = tab; _activeTab = tab;
if (IsNew) { return; } if (IsNew) { return; }
if (tab == "tags" && _tags is null) { await ReloadTagsAsync(); } if (tab == "tags" && _refs is null) { await ReloadReferencesAsync(); }
else if (tab == "vtags" && _vtags is null) { await ReloadVirtualTagsAsync(); } else if (tab == "vtags" && _vtags is null) { await ReloadVirtualTagsAsync(); }
else if (tab == "alarms" && _alarms is null) { await ReloadAlarmsAsync(); } else if (tab == "alarms" && _alarms is null) { await ReloadAlarmsAsync(); }
} }
// --- Tags tab handlers (mirror GlobalUns; the owning equipment is fixed = EquipmentId) --- // --- Tags tab handlers (v3 reference-only; the owning equipment is fixed = EquipmentId) ---
private async Task ReloadTagsAsync() private async Task ReloadReferencesAsync()
{ {
_tags = await Svc.LoadTagsForEquipmentAsync(EquipmentId!); _refs = await Svc.LoadReferencesForEquipmentAsync(EquipmentId!);
// Seed the per-row override edit buffer so each row's <input> binds to a live value.
_overrideEdits.Clear();
foreach (var r in _refs) { _overrideEdits[r.UnsTagReferenceId] = r.DisplayNameOverride; }
} }
private async Task OpenAddTag() private void OpenAddReference()
{ {
_tagError = null; _refError = null;
_tagModalIsNew = true; _refModalVisible = true;
_tagModalExisting = null;
_tagDriverOptions = await Svc.LoadTagDriversForEquipmentAsync(EquipmentId!);
_tagModalVisible = true;
} }
private async Task OpenEditTag(string tagId) private async Task OnReferencesCommittedAsync()
{ {
_tagError = null; _refModalVisible = false;
var dto = await Svc.LoadTagAsync(tagId); await ReloadReferencesAsync();
if (dto is null) { _tagError = "That tag no longer exists; the list was refreshed."; await ReloadTagsAsync(); return; }
_tagModalIsNew = false;
_tagModalExisting = dto;
_tagDriverOptions = await Svc.LoadTagDriversForEquipmentAsync(EquipmentId!);
_tagModalVisible = true;
} }
private async Task OnTagSavedAsync() private async Task SaveOverride(EquipmentReferenceRow r)
{ {
_tagModalVisible = false; _refError = null;
await ReloadTagsAsync(); var edited = _overrideEdits.GetValueOrDefault(r.UnsTagReferenceId);
var res = await Svc.SetReferenceOverrideAsync(r.UnsTagReferenceId, edited, r.RowVersion);
if (res.Ok) { await ReloadReferencesAsync(); }
else { _refError = res.Error; }
} }
private async Task DeleteTag(string tagId) private async Task RemoveReference(EquipmentReferenceRow r)
{ {
_tagModalVisible = false; _refError = null;
_tagError = null; var res = await Svc.RemoveReferenceAsync(r.UnsTagReferenceId, r.RowVersion);
// Load the tag fresh to capture its current RowVersion for the optimistic-concurrency delete. if (res.Ok) { await ReloadReferencesAsync(); }
var dto = await Svc.LoadTagAsync(tagId); else { _refError = res.Error; }
if (dto is null) { await ReloadTagsAsync(); return; }
var r = await Svc.DeleteTagAsync(tagId, dto.RowVersion);
if (r.Ok) { await ReloadTagsAsync(); }
else { _tagError = r.Error; }
} }
// --- Virtual Tags tab handlers --- // --- Virtual Tags tab handlers ---
@@ -478,7 +471,7 @@ else
// path lands on Details because the field initializes to "details" and a fresh page instance // path lands on Details because the field initializes to "details" and a fresh page instance
// starts with that initial value. The Tags/Virtual Tags lists are reset to null below so the // starts with that initial value. The Tags/Virtual Tags lists are reset to null below so the
// lazy loaders re-fetch for the (possibly different) equipment this parameter set targets. // lazy loaders re-fetch for the (possibly different) equipment this parameter set targets.
_tags = null; _refs = null;
_vtags = null; _vtags = null;
_alarms = null; _alarms = null;
if (!IsNew) if (!IsNew)
@@ -489,7 +482,6 @@ else
LoadFormFrom(_equipment); LoadFormFrom(_equipment);
var ctx = await Svc.LoadEquipmentPickContextAsync(_equipment.UnsLineId); var ctx = await Svc.LoadEquipmentPickContextAsync(_equipment.UnsLineId);
_lineOptions = ctx.Lines; _lineOptions = ctx.Lines;
_driverOptions = ctx.Drivers;
} }
} }
else else
@@ -497,7 +489,6 @@ else
_form = new FormModel { UnsLineId = LineId ?? "" }; _form = new FormModel { UnsLineId = LineId ?? "" };
var ctx = await Svc.LoadEquipmentPickContextAsync(LineId); var ctx = await Svc.LoadEquipmentPickContextAsync(LineId);
_lineOptions = ctx.Lines; _lineOptions = ctx.Lines;
_driverOptions = ctx.Drivers;
} }
_loading = false; _loading = false;
} }
@@ -510,7 +501,6 @@ else
Name = e.Name, Name = e.Name,
MachineCode = e.MachineCode, MachineCode = e.MachineCode,
UnsLineId = e.UnsLineId, UnsLineId = e.UnsLineId,
DriverInstanceId = e.DriverInstanceId,
ZTag = e.ZTag, ZTag = e.ZTag,
SAPID = e.SAPID, SAPID = e.SAPID,
Manufacturer = e.Manufacturer, Manufacturer = e.Manufacturer,
@@ -536,7 +526,6 @@ else
_form.Name, _form.Name,
_form.MachineCode, _form.MachineCode,
_form.UnsLineId, _form.UnsLineId,
_form.DriverInstanceId,
_form.ZTag, _form.ZTag,
_form.SAPID, _form.SAPID,
_form.Manufacturer, _form.Manufacturer,
@@ -582,7 +571,6 @@ else
public string Name { get; set; } = ""; public string Name { get; set; } = "";
[Required] public string MachineCode { get; set; } = ""; [Required] public string MachineCode { get; set; } = "";
[Required] public string UnsLineId { get; set; } = ""; [Required] public string UnsLineId { get; set; } = "";
public string? DriverInstanceId { get; set; }
public string? ZTag { get; set; } public string? ZTag { get; set; }
public string? SAPID { get; set; } public string? SAPID { get; set; }
public string? Manufacturer { get; set; } public string? Manufacturer { get; set; }
@@ -29,6 +29,13 @@
[Parameter] public bool ReadOnly { get; set; } = false; [Parameter] public bool ReadOnly { get; set; } = false;
[Parameter] public bool ShowToolbar { get; set; } = true; [Parameter] public bool ShowToolbar { get; set; } = true;
/// <summary>
/// Owning-equipment context for equipment-relative <c>{{equip}}/&lt;RefName&gt;</c> completion +
/// diagnostics. Set on the per-equipment VirtualTag / ScriptedAlarm modals; left null on the shared
/// ScriptEdit page (where the token cannot resolve to a single equipment).
/// </summary>
[Parameter] public string? EquipmentId { get; set; }
/// <summary> /// <summary>
/// Fires whenever Monaco's marker set updates (after the 500 ms diagnostic /// Fires whenever Monaco's marker set updates (after the 500 ms diagnostic
/// debounce). The marker DTO is not modelled yet — typed as object[] until a /// debounce). The marker DTO is not modelled yet — typed as object[] until a
@@ -61,7 +68,8 @@
{ {
value = Value ?? "", value = Value ?? "",
language = Language, language = Language,
readOnly = ReadOnly readOnly = ReadOnly,
equipmentId = EquipmentId
}, },
_dotNetRef); _dotNetRef);
_initialized = true; _initialized = true;
@@ -69,6 +69,28 @@
/// </summary> /// </summary>
[Parameter] public EventCallback OnRootsChanged { get; set; } [Parameter] public EventCallback OnRootsChanged { get; set; }
// ---------------------------------------------------------------------------
// v3 Batch 3 — raw-tag PICKER mode (opt-in; default off keeps the /raw usage byte-unchanged).
// In picker mode the mutation context-menus are suppressed: Tag leaves render a multi-select
// checkbox, and Device/TagGroup containers offer a "select all tags below" menu. The equipment
// page's "+ Add reference" modal drives this against a single cluster-scoped root.
// ---------------------------------------------------------------------------
/// <summary>When <c>true</c>, render the tree as a raw-tag picker (checkboxes + select-all) instead of
/// the editing tree. Default <c>false</c> — the /raw editing usage is unaffected.</summary>
[Parameter] public bool PickerMode { get; set; }
/// <summary>The shared, caller-owned set of selected raw <c>TagId</c>s (picker mode only). The component
/// mutates it in place and raises <see cref="OnSelectionChanged"/> after every change.</summary>
[Parameter] public HashSet<string>? SelectedTagIds { get; set; }
/// <summary>Raised after the selection set changes (picker mode) so the host can update its count / footer.</summary>
[Parameter] public EventCallback OnSelectionChanged { get; set; }
/// <summary>Supplies the raw <c>TagId</c>s beneath a Device/TagGroup node for the "select all tags below"
/// affordance (picker mode). The host wires this to <c>IUnsTreeService.LoadDescendantTagIdsAsync</c>.</summary>
[Parameter] public Func<RawNode, Task<IReadOnlyList<string>>>? ResolveDescendantTagIds { get; set; }
// --- Configure-driver modal (edit) --- // --- Configure-driver modal (edit) ---
private bool _driverCfgVisible; private bool _driverCfgVisible;
private string? _driverCfgId; private string? _driverCfgId;
@@ -198,6 +220,12 @@
private RenderFragment RenderRowBody(RawNode node, bool muted) => __builder => private RenderFragment RenderRowBody(RawNode node, bool muted) => __builder =>
{ {
<span class="d-inline-flex align-items-center gap-1"> <span class="d-inline-flex align-items-center gap-1">
@if (PickerMode && node.Kind == RawNodeKind.Tag)
{
<input type="checkbox" class="form-check-input me-1"
checked="@IsTagSelected(node)"
@onchange="@(e => ToggleTagSelection(node, e.Value is true))" />
}
<span aria-hidden="true">@KindIcon(node.Kind)</span> <span aria-hidden="true">@KindIcon(node.Kind)</span>
<span class="mono small @(muted ? "text-muted" : "")">@node.DisplayName</span> <span class="mono small @(muted ? "text-muted" : "")">@node.DisplayName</span>
@if (muted) @if (muted)
@@ -270,17 +298,67 @@
// named handlers below are the seam Wave B/C replaces with the real modals. // named handlers below are the seam Wave B/C replaces with the real modals.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
private IReadOnlyList<ContextMenuItem> MenuFor(RawNode node) => node.Kind switch private IReadOnlyList<ContextMenuItem> MenuFor(RawNode node)
{ {
RawNodeKind.Cluster => ClusterMenu(node), // Picker mode: no mutation menus. Device/TagGroup containers get a "select all tags below"
RawNodeKind.Folder => FolderMenu(node), // affordance; every other kind (and Tag leaves, which carry the checkbox) has no menu.
RawNodeKind.Driver => DriverMenu(node), if (PickerMode)
RawNodeKind.Device => DeviceMenu(node), {
RawNodeKind.TagGroup => TagGroupMenu(node), return node.Kind is RawNodeKind.Device or RawNodeKind.TagGroup
RawNodeKind.Tag => TagMenu(node), ? PickerContainerMenu(node)
_ => Array.Empty<ContextMenuItem>(), // Enterprise: label only, no menu. : Array.Empty<ContextMenuItem>();
}
return node.Kind switch
{
RawNodeKind.Cluster => ClusterMenu(node),
RawNodeKind.Folder => FolderMenu(node),
RawNodeKind.Driver => DriverMenu(node),
RawNodeKind.Device => DeviceMenu(node),
RawNodeKind.TagGroup => TagGroupMenu(node),
RawNodeKind.Tag => TagMenu(node),
_ => Array.Empty<ContextMenuItem>(), // Enterprise: label only, no menu.
};
}
// --- Picker-mode menu + selection helpers ---
private List<ContextMenuItem> PickerContainerMenu(RawNode node) => new()
{
new() { Label = "Select all tags below", Icon = "☑", OnClick = () => SelectAllUnderAsync(node) },
new() { Label = "Clear all tags below", Icon = "☐", OnClick = () => ClearAllUnderAsync(node) },
}; };
private bool IsTagSelected(RawNode node) =>
node.EntityId is not null && SelectedTagIds is not null && SelectedTagIds.Contains(node.EntityId);
private async Task ToggleTagSelection(RawNode node, bool selected)
{
if (SelectedTagIds is null || node.EntityId is null) { return; }
if (selected) { SelectedTagIds.Add(node.EntityId); }
else { SelectedTagIds.Remove(node.EntityId); }
await OnSelectionChanged.InvokeAsync();
StateHasChanged();
}
private async Task SelectAllUnderAsync(RawNode node)
{
if (SelectedTagIds is null || ResolveDescendantTagIds is null) { return; }
var ids = await ResolveDescendantTagIds(node);
foreach (var id in ids) { SelectedTagIds.Add(id); }
await OnSelectionChanged.InvokeAsync();
StateHasChanged();
}
private async Task ClearAllUnderAsync(RawNode node)
{
if (SelectedTagIds is null || ResolveDescendantTagIds is null) { return; }
var ids = await ResolveDescendantTagIds(node);
foreach (var id in ids) { SelectedTagIds.Remove(id); }
await OnSelectionChanged.InvokeAsync();
StateHasChanged();
}
private List<ContextMenuItem> ClusterMenu(RawNode node) => new() private List<ContextMenuItem> ClusterMenu(RawNode node) => new()
{ {
new() { Label = "New folder", Icon = "📁", OnClick = () => OnNewFolder(node) }, new() { Label = "New folder", Icon = "📁", OnClick = () => OnNewFolder(node) },
@@ -0,0 +1,125 @@
@* v3 Batch 3 "+ Add reference" picker: reuses the Raw project tree (RawTree) in picker mode to
multi-select raw tags, scoped to the equipment's own cluster. The cluster scope is structural —
IUnsTreeService.LoadReferencePickerRootAsync hands back a SINGLE cluster root, so raw tags of any
other cluster are simply unreachable through the tree, not merely hidden. On commit the selected
tag ids are added as UnsTagReference rows via AddReferencesAsync; the host reloads its Tags list. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw
@inject IUnsTreeService Svc
@if (Visible)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add tag references</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelAsync"></button>
</div>
<div class="modal-body">
<p class="text-muted small mb-2">
Pick raw tags to reference under this equipment. The tree is scoped to the
equipment's cluster. Tick individual tags, or use a device / tag-group's
<em>“Select all tags below”</em> menu to pull many at once.
</p>
@if (_loading)
{
<p class="text-muted"><span class="spinner-border spinner-border-sm me-1"></span>Loading…</p>
}
else if (_roots.Count == 0)
{
<p class="text-muted">This equipment does not resolve to a cluster, so there are no raw tags to reference.</p>
}
else
{
<div class="border rounded p-2" style="max-height:50vh; overflow:auto">
<RawTree Roots="_roots" PickerMode="true" SelectedTagIds="_selected"
OnSelectionChanged="OnSelectionChanged"
ResolveDescendantTagIds="ResolveDescendantsAsync" />
</div>
}
@if (!string.IsNullOrWhiteSpace(_error))
{
<div class="text-danger small mt-2">@_error</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="CancelAsync" disabled="@_busy">Cancel</button>
<button type="button" class="btn btn-primary" @onclick="CommitAsync" disabled="@(_busy || _selected.Count == 0)">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
Add @_selected.Count reference@(_selected.Count == 1 ? "" : "s")
</button>
</div>
</div>
</div>
</div>
}
@code {
/// <summary>Whether the modal is shown. The host owns this flag.</summary>
[Parameter] public bool Visible { get; set; }
/// <summary>The equipment the selected raw tags are referenced under.</summary>
[Parameter] public string? EquipmentId { get; set; }
/// <summary>Raised after references are committed so the host can reload its Tags list and close.</summary>
[Parameter] public EventCallback OnCommitted { get; set; }
/// <summary>Raised when the user cancels so the host can close.</summary>
[Parameter] public EventCallback OnCancel { get; set; }
private IReadOnlyList<RawNode> _roots = Array.Empty<RawNode>();
private readonly HashSet<string> _selected = new(StringComparer.Ordinal);
private bool _busy;
private bool _loading;
private string? _error;
private bool _wasVisible;
protected override async Task OnParametersSetAsync()
{
// Reset + reload only on the false→true transition so re-renders while open don't wipe state.
if (Visible && !_wasVisible)
{
_selected.Clear();
_error = null;
_roots = Array.Empty<RawNode>();
_loading = true;
StateHasChanged();
var root = string.IsNullOrEmpty(EquipmentId)
? null
: await Svc.LoadReferencePickerRootAsync(EquipmentId);
_roots = root is null ? Array.Empty<RawNode>() : new[] { root };
_loading = false;
}
_wasVisible = Visible;
}
private Task<IReadOnlyList<string>> ResolveDescendantsAsync(RawNode node) =>
Svc.LoadDescendantTagIdsAsync(node);
// RawTree mutates _selected in place; this just re-renders the footer count.
private void OnSelectionChanged() => StateHasChanged();
private async Task CommitAsync()
{
if (_selected.Count == 0) { return; }
_busy = true;
_error = null;
try
{
var res = await Svc.AddReferencesAsync(EquipmentId!, _selected.ToList());
if (res.Ok) { await OnCommitted.InvokeAsync(); }
else { _error = res.Error; }
}
finally
{
_busy = false;
}
}
private Task CancelAsync() => OnCancel.InvokeAsync();
}
@@ -2,9 +2,10 @@
the modal parses it into EquipmentInput rows and calls ImportEquipmentAsync, then shows the the modal parses it into EquipmentInput rows and calls ImportEquipmentAsync, then shows the
Inserted / Skipped / Errors summary in place. The host owns visibility; "Close" raises OnImported Inserted / Skipped / Errors summary in place. The host owns visibility; "Close" raises OnImported
so the page can reload the whole tree (an import can add equipment across many lines/clusters). so the page can reload the whole tree (an import can add equipment across many lines/clusters).
Required header columns (in order): Name, MachineCode, UnsLineId, DriverInstanceId. Required header columns (in order): Name, MachineCode, UnsLineId.
Optional: ZTag, SAPID, Manufacturer, Model. Existing rows are detected by MachineCode and Optional: ZTag, SAPID, Manufacturer, Model. Existing rows are detected by MachineCode and
skipped (additive-only — no updates), matching the retired /clusters/{id}/equipment/import page. *@ skipped (additive-only — no updates), matching the retired /clusters/{id}/equipment/import page.
v3: equipment no longer carries a driver, so the old DriverInstanceId column is gone. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns @using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@inject IUnsTreeService Svc @inject IUnsTreeService Svc
@@ -21,7 +22,7 @@
<div class="modal-body"> <div class="modal-body">
<p class="text-muted small mb-2"> <p class="text-muted small mb-2">
Paste CSV below. Required header columns (in order): Paste CSV below. Required header columns (in order):
<span class="mono">Name, MachineCode, UnsLineId, DriverInstanceId</span>. <span class="mono">Name, MachineCode, UnsLineId</span>.
Optional: <span class="mono">ZTag, SAPID, Manufacturer, Model</span>. Optional: <span class="mono">ZTag, SAPID, Manufacturer, Model</span>.
Each row inserts one equipment with a freshly-generated EquipmentId. Existing rows Each row inserts one equipment with a freshly-generated EquipmentId. Existing rows
are detected by MachineCode and skipped (additive-only — no updates). are detected by MachineCode and skipped (additive-only — no updates).
@@ -29,7 +30,7 @@
<textarea class="form-control form-control-sm mono" rows="12" <textarea class="form-control form-control-sm mono" rows="12"
@bind="_csv" @bind:event="oninput" disabled="@_busy" @bind="_csv" @bind:event="oninput" disabled="@_busy"
placeholder="Name,MachineCode,UnsLineId,DriverInstanceId,ZTag,SAPID,Manufacturer,Model&#10;mixer-01,MX_001,LINE-1,drv-modbus-01,ZT-12345,SAP-9876,Siemens,SIMATIC-1500"></textarea> placeholder="Name,MachineCode,UnsLineId,ZTag,SAPID,Manufacturer,Model&#10;mixer-01,MX_001,LINE-1,ZT-12345,SAP-9876,Siemens,SIMATIC-1500"></textarea>
<div class="form-text">Simple comma-separated values only — fields containing commas are not supported.</div> <div class="form-text">Simple comma-separated values only — fields containing commas are not supported.</div>
@if (!string.IsNullOrWhiteSpace(_parseError)) @if (!string.IsNullOrWhiteSpace(_parseError))
@@ -72,8 +73,8 @@
} }
@code { @code {
// Required, in order; DriverInstanceId may be left blank per row (driver-less equipment). // Required, in order. v3: equipment no longer binds a driver, so DriverInstanceId is gone.
private static readonly string[] RequiredColumns = ["Name", "MachineCode", "UnsLineId", "DriverInstanceId"]; private static readonly string[] RequiredColumns = ["Name", "MachineCode", "UnsLineId"];
/// <summary>Whether the modal is shown. The host owns this flag.</summary> /// <summary>Whether the modal is shown. The host owns this flag.</summary>
[Parameter] public bool Visible { get; set; } [Parameter] public bool Visible { get; set; }
@@ -133,7 +134,7 @@
/// <summary> /// <summary>
/// Parses the pasted CSV into <see cref="EquipmentInput"/> rows. Requires a header row whose first /// Parses the pasted CSV into <see cref="EquipmentInput"/> rows. Requires a header row whose first
/// four columns are Name, MachineCode, UnsLineId, DriverInstanceId (case-insensitive); the optional /// three columns are Name, MachineCode, UnsLineId (case-insensitive); the optional
/// ZTag/SAPID/Manufacturer/Model follow. Blank optional cells parse as <c>null</c>. Returns /// ZTag/SAPID/Manufacturer/Model follow. Blank optional cells parse as <c>null</c>. Returns
/// <c>null</c> (and sets <see cref="_parseError"/>) when the CSV is empty or the header is wrong. /// <c>null</c> (and sets <see cref="_parseError"/>) when the CSV is empty or the header is wrong.
/// </summary> /// </summary>
@@ -169,11 +170,10 @@
Name: parts[0], Name: parts[0],
MachineCode: parts[1], MachineCode: parts[1],
UnsLineId: parts[2], UnsLineId: parts[2],
DriverInstanceId: NullIfEmpty(parts, 3), ZTag: NullIfEmpty(parts, 3),
ZTag: NullIfEmpty(parts, 4), SAPID: NullIfEmpty(parts, 4),
SAPID: NullIfEmpty(parts, 5), Manufacturer: NullIfEmpty(parts, 5),
Manufacturer: NullIfEmpty(parts, 6), Model: NullIfEmpty(parts, 6),
Model: NullIfEmpty(parts, 7),
SerialNumber: null, SerialNumber: null,
HardwareRevision: null, HardwareRevision: null,
SoftwareRevision: null, SoftwareRevision: null,
@@ -1,557 +0,0 @@
@* Create/edit modal for an equipment-bound tag, wired straight into IUnsTreeService. The host page
owns visibility and supplies the owning equipment id (create) or the loaded TagEditDto (edit), plus
the equipment-scoped candidate-driver list. Tree tags are always equipment-bound (decision #110), so
the legacy SystemPlatform/FolderPath branch is dropped entirely — there is no equipment selector
either, the owning equipment is fixed. On a successful save it raises OnSaved so the host can
refresh the equipment's children in place. *@
@using System.ComponentModel.DataAnnotations
@using System.Text.Json
@using Microsoft.AspNetCore.Components.Forms
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@inject IUnsTreeService Svc
@if (Visible)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<EditForm Model="_form" OnValidSubmit="SaveAsync" FormName="tagModal">
<DataAnnotationsValidator />
<div class="modal-header">
<h5 class="modal-title">@(IsNew ? "New tag" : "Edit tag")</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelAsync"></button>
</div>
<div class="modal-body">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="tag-id">TagId</label>
<InputText id="tag-id" @bind-Value="_form.TagId" disabled="@(!IsNew)"
class="form-control form-control-sm mono"
placeholder="tag-line3-temp-01" />
<ValidationMessage For="@(() => _form.TagId)" />
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="tag-name">Name</label>
<InputText id="tag-name" @bind-Value="_form.Name" class="form-control form-control-sm"
placeholder="Temperature setpoint" />
<ValidationMessage For="@(() => _form.Name)" />
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="tag-driver">Driver instance</label>
<InputSelect id="tag-driver" @bind-Value="_form.DriverInstanceId" @bind-Value:after="OnDriverChanged" class="form-select form-select-sm">
<option value="">— pick a driver —</option>
@foreach (var d in Drivers)
{
<option value="@d.Id">@d.Display</option>
}
</InputSelect>
<ValidationMessage For="@(() => _form.DriverInstanceId)" />
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="tag-dtype">Data type</label>
<InputSelect id="tag-dtype" @bind-Value="_form.DataType" class="form-select form-select-sm">
@foreach (var dt in DataTypes)
{
<option value="@dt">@dt</option>
}
</InputSelect>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="tag-access">Access level</label>
<InputSelect id="tag-access" @bind-Value="_form.AccessLevel" class="form-select form-select-sm">
<option value="@TagAccessLevel.Read">Read</option>
<option value="@TagAccessLevel.ReadWrite">ReadWrite</option>
</InputSelect>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">WriteIdempotent</label>
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.WriteIdempotent" class="form-check-input" />
<label class="form-check-label">Safe to retry writes (decision #4445)</label>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label" for="tag-pgroup">PollGroupId (optional)</label>
<InputText id="tag-pgroup" @bind-Value="_form.PollGroupId" class="form-control form-control-sm mono" />
</div>
<div class="mb-3">
<label class="form-label">Tag config</label>
@{
var editorType = TagConfigEditorMap.Resolve(SelectedDriverType);
}
@if (string.IsNullOrEmpty(_form.DriverInstanceId))
{
<div class="form-text">Pick a driver above to configure this tag.</div>
}
else if (IsGalaxyDriver)
{
@* GalaxyMxGateway has no typed TagConfigEditorMap editor; instead a Galaxy point is
authored as {"FullName":"tag_name.AttributeName"}. Offer the live-browse picker
(against the selected gateway's DriverConfig) plus a manual raw-JSON fallback. *@
<button type="button" class="btn btn-outline-secondary btn-sm mb-2"
@onclick="@(() => _showGalaxyPicker = true)">
Browse Galaxy
</button>
<InputTextArea id="tag-config" @bind-Value="_form.TagConfig" rows="3"
class="form-control form-control-sm mono"
placeholder='{ "FullName": "DelmiaReceiver_001.DownloadPath" }' />
<div class="form-text">
The Galaxy reference, stored as <code>{"FullName":"tag_name.AttributeName"}</code>.
Pick one via <strong>Browse Galaxy</strong> or edit the JSON directly.
</div>
@if (_showGalaxyPicker)
{
<DriverTagPicker @bind-Visible="_showGalaxyPicker"
Title="Galaxy address"
CurrentAddress="@_galaxyAddress"
OnPickAddress="@OnGalaxyAddressPicked">
<GalaxyAddressPickerBody CurrentAddress="@_galaxyAddress"
CurrentAddressChanged="@((s) => _galaxyAddress = s)"
@bind-SelectedIsAlarm="_galaxyPickedIsAlarm"
GetConfigJson="@(() => SelectedDriverConfig)" />
</DriverTagPicker>
}
}
else if (editorType is not null)
{
<DynamicComponent Type="editorType" Parameters="BuildEditorParameters()" />
}
else
{
<InputTextArea id="tag-config" @bind-Value="_form.TagConfig" rows="6"
class="form-control form-control-sm mono"
placeholder='{ "register": 40001, "scale": 0.1 }' />
<div class="form-text">Schemaless per driver type. Validated server-side at deploy.</div>
}
<ValidationMessage For="@(() => _form.TagConfig)" />
</div>
@* Driver-agnostic server-side HistoryRead intent. Distinct from the native-alarm
"Historize to AVEVA" toggle below: THIS gates TAG-VALUE history (root keys
`isHistorized` / `historianTagname`, read by AddressSpaceComposer.ExtractTagHistorize),
merged onto the canonical TagConfig via the pure TagHistorizeConfig seam so it
composes with the typed editor's driver-specific fields (both preserve unknown keys).
Shown for EVERY driver once one is picked. *@
@if (!string.IsNullOrEmpty(_form.DriverInstanceId))
{
<div class="mb-3">
<label class="form-label">History</label>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="tag-historize"
checked="@_historizeState.IsHistorized"
@onchange="OnHistorizeChanged" />
<label class="form-check-label" for="tag-historize">Historize this tag (expose OPC UA HistoryRead)</label>
</div>
<div class="form-text">
When checked, the server serves OPC UA HistoryRead over this tag's value
from the configured historian.
</div>
@if (_historizeState.IsHistorized)
{
<div class="mt-2">
<label class="form-label" for="tag-historian-tagname">Historian tagname (override, optional)</label>
<input type="text" class="form-control form-control-sm mono" id="tag-historian-tagname"
value="@_historizeState.HistorianTagname"
@onchange="OnHistorianTagnameChanged" />
<div class="form-text">Blank defaults the historian tagname to this tag's driver FullName.</div>
</div>
}
</div>
}
@* Driver-agnostic array-shape intent. Merges the root `isArray` / `arrayLength`
keys onto the canonical TagConfig via the pure TagArrayConfig seam so it composes
with the typed editor's driver-specific fields (both preserve unknown keys). When
checked, the server materialises a 1-D array node (ValueRank=1). Shown for EVERY
driver once one is picked — same place/pattern as the historize control above. *@
@if (!string.IsNullOrEmpty(_form.DriverInstanceId))
{
<div class="mb-3">
<label class="form-label">Array</label>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="tag-is-array"
checked="@_arrayState.IsArray"
@onchange="OnIsArrayChanged" />
<label class="form-check-label" for="tag-is-array">This tag is an array (1-D)</label>
</div>
<div class="form-text">
When checked, the server materialises this tag as a fixed-length 1-D array node.
</div>
@if (_arrayState.IsArray)
{
<div class="mt-2">
<label class="form-label" for="tag-array-length">Array length</label>
<input type="number" min="1" step="1" class="form-control form-control-sm mono" id="tag-array-length"
value="@_arrayState.ArrayLength"
@onchange="OnArrayLengthChanged" />
<div class="form-text">Number of elements (must be a positive whole number).</div>
</div>
}
</div>
}
@* Native-alarm options: shown only when the TagConfig carries an `alarm` object (the tag
is a Part 9 condition). The "Historize to AVEVA" toggle edits the alarm.historizeToAveva
opt-out (bool?, unchecked-via-clear ⇒ absent ⇒ historize default-on at the server gate;
explicit false suppresses the durable AVEVA write — same posture as scripted alarms). *@
@if (HasNativeAlarm)
{
<div class="mb-3">
<label class="form-label">Native alarm</label>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="tag-alarm-historize"
checked="@AlarmHistorizeToAveva"
@onchange="OnAlarmHistorizeChanged" />
<label class="form-check-label" for="tag-alarm-historize">Historize to AVEVA</label>
</div>
<div class="form-text">
When unchecked, this alarm's transitions are NOT written to the AVEVA historian
(the live alerts feed is unaffected). Checked is the default.
</div>
</div>
}
@if (!string.IsNullOrWhiteSpace(_error))
{
<div class="text-danger small mt-2">@_error</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="CancelAsync" disabled="@_busy">Cancel</button>
<button type="submit" class="btn btn-primary" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
@(IsNew ? "Create" : "Save changes")
</button>
</div>
</EditForm>
</div>
</div>
</div>
}
@code {
private static readonly string[] DataTypes =
["Boolean", "SByte", "Byte", "Int16", "UInt16", "Int32", "UInt32",
"Int64", "UInt64", "Float", "Double", "String", "DateTime", "Guid", "ByteString"];
/// <summary>Whether the modal is shown. The host owns this flag.</summary>
[Parameter] public bool Visible { get; set; }
/// <summary><c>true</c> to create a new tag; <c>false</c> to edit <see cref="Existing"/>.</summary>
[Parameter] public bool IsNew { get; set; }
/// <summary>The owning equipment id the created tag binds to (used only on create).</summary>
[Parameter] public string? EquipmentId { get; set; }
/// <summary>The tag being edited, when <see cref="IsNew"/> is <c>false</c>.</summary>
[Parameter] public TagEditDto? Existing { get; set; }
/// <summary>The candidate drivers — scoped to the equipment's cluster by the host — as
/// <c>(Id, Display, DriverType, DriverConfig)</c> tuples. <c>DriverType</c> drives typed-editor dispatch;
/// <c>DriverConfig</c> feeds the Galaxy live-browse picker for GalaxyMxGateway drivers.</summary>
[Parameter] public IReadOnlyList<(string Id, string Display, string DriverType, string DriverConfig)> Drivers { get; set; } = Array.Empty<(string, string, string, string)>();
/// <summary>Raised after a successful create/save so the host can refresh the equipment's children and close.</summary>
[Parameter] public EventCallback OnSaved { get; set; }
/// <summary>Raised when the user cancels so the host can close.</summary>
[Parameter] public EventCallback OnCancel { get; set; }
private FormModel _form = new();
private bool _busy;
private string? _error;
// Galaxy live-browse picker state. Only meaningful when the selected driver is a GalaxyMxGateway.
private bool _showGalaxyPicker;
private string _galaxyAddress = "";
// True when the attribute most-recently selected in the Galaxy picker is itself an alarm
// (DriverAttributeInfo.IsAlarm). On commit, this pre-fills a default native-alarm object into the
// TagConfig (when none is present) so the operator can author the alarm in one pass.
private bool _galaxyPickedIsAlarm;
// Driver-agnostic server-side HistoryRead intent (root `isHistorized` / `historianTagname`), reflected
// for the "Historize this tag" controls. Re-read from _form.TagConfig whenever the modal (re)opens or
// the driver changes; the change handlers merge it back onto _form.TagConfig via TagHistorizeConfig.
private TagHistorizeConfig.HistorizeState _historizeState;
// Driver-agnostic array-shape intent (root `isArray` / `arrayLength`), reflected for the array controls.
// Re-read from _form.TagConfig whenever the modal (re)opens or the driver changes; the change handlers
// merge it back onto _form.TagConfig via TagArrayConfig (same pattern as _historizeState above).
private TagArrayConfig.ArrayState _arrayState;
// The DriverType of the currently-selected driver (drives editor dispatch). Null when no driver chosen.
private string? SelectedDriverType =>
Drivers.FirstOrDefault(d => d.Id == _form.DriverInstanceId).DriverType;
// The DriverConfig JSON of the currently-selected driver — fed to the Galaxy picker so it browses the
// right gateway. Defaults to "{}" when no driver is chosen or the config is empty.
private string SelectedDriverConfig
{
get
{
var cfg = Drivers.FirstOrDefault(d => d.Id == _form.DriverInstanceId).DriverConfig;
return string.IsNullOrEmpty(cfg) ? "{}" : cfg;
}
}
// True when the selected driver is a GalaxyMxGateway — Galaxy points are authored as
// {"FullName":"tag_name.AttributeName"} via the live-browse picker rather than a typed editor.
private bool IsGalaxyDriver => SelectedDriverType == "GalaxyMxGateway";
// When the operator switches drivers, the previous driver's TagConfig schema no longer applies —
// reset it so the newly-dispatched typed editor starts clean (no stale/leaked keys from the old
// driver). Fires only on a user dropdown change (@bind-Value:after), not on the initial edit-load.
private void OnDriverChanged()
{
_form.TagConfig = "{}";
// The Galaxy reference belongs to the previous driver; clear the picker's working address too.
_galaxyAddress = "";
_galaxyPickedIsAlarm = false;
// The reset TagConfig carries no history intent — reflect that in the historize controls.
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
// Likewise the reset TagConfig carries no array intent.
_arrayState = TagArrayConfig.Read(_form.TagConfig);
}
// The operator picked a Galaxy reference (tag_name.AttributeName); store it as the canonical
// {"FullName":"..."} TagConfig the Galaxy driver resolves to an MXAccess reference. Default
// (PascalCase) serialization yields the "FullName" key the driver/walker reads.
//
// When the picked attribute is itself an alarm (DriverAttributeInfo.IsAlarm), pre-seed a default
// native-alarm `alarm` object so the tag materialises as a Part 9 condition and Task 3's
// "Historize to AVEVA" toggle auto-appears — sparing the operator hand-written JSON. NativeAlarmModel
// .SeedDefaultAlarm only seeds when absent (never overwrites an authored alarm) and preserves FullName.
private void OnGalaxyAddressPicked(string address)
{
_galaxyAddress = address;
// Re-picking a Galaxy address owns ONLY the address-derived FullName key — apply it over the EXISTING
// TagConfig so every other user-edited field survives verbatim. This preserves a hand-authored `alarm`
// object (FB-4: a re-pick must never clobber edited alarm fields) as well as the root history/array
// intent and any driver/unknown keys. TagConfigJson.SetFullName uses the same preserve-unknown idiom
// as the historize/array merge seams.
var config = TagConfigJson.SetFullName(_form.TagConfig, address);
_form.TagConfig = _galaxyPickedIsAlarm
? NativeAlarmModel.SeedDefaultAlarm(config)
: config;
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
_arrayState = TagArrayConfig.Read(_form.TagConfig);
}
private IDictionary<string, object> BuildEditorParameters() => new Dictionary<string, object>
{
["ConfigJson"] = _form.TagConfig,
["ConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => _form.TagConfig = v),
["DriverType"] = SelectedDriverType ?? "",
["GetDriverConfigJson"] = (Func<string>)(() => SelectedDriverConfig),
};
// Cache a single NativeAlarmModel parse keyed on the current TagConfig string, so the two computed
// properties below don't each re-parse the JSON on every render. Re-parsed only when TagConfig changes.
private NativeAlarmModel _nativeAlarm = NativeAlarmModel.FromJson("{}");
private string? _nativeAlarmSource;
private NativeAlarmModel NativeAlarm
{
get
{
if (_nativeAlarmSource != _form.TagConfig)
{
_nativeAlarmSource = _form.TagConfig;
_nativeAlarm = NativeAlarmModel.FromJson(_form.TagConfig);
}
return _nativeAlarm;
}
}
// True when the current TagConfig carries an `alarm` object — i.e. the tag is materialised as a Part 9
// native-alarm condition rather than a value variable. Gates the "Historize to AVEVA" toggle's visibility.
private bool HasNativeAlarm => NativeAlarm.IsAlarm;
// The native alarm's HistorizeToAveva intent reflected for the checkbox: absent (null) ⇒ historize
// (default-on at the server gate), so the box is checked for both null and explicit true; only an
// explicit false leaves it unchecked.
private bool AlarmHistorizeToAveva => NativeAlarm.HistorizeToAveva != false;
// Toggle the alarm.historizeToAveva opt-out in the raw TagConfig. Checked ⇒ remove the key (null ⇒
// absent ⇒ historize default-on); unchecked ⇒ write an explicit false (suppress the durable AVEVA row).
// Unknown keys at the root + inside `alarm` are preserved across the edit (NativeAlarmModel round-trip).
private void OnAlarmHistorizeChanged(ChangeEventArgs e)
{
var model = NativeAlarmModel.FromJson(_form.TagConfig);
if (!model.IsAlarm) { return; }
model.HistorizeToAveva = e.Value is true ? null : false;
_form.TagConfig = model.ToJson();
}
// Toggle the root `isHistorized` tag-value history intent in the raw TagConfig, merged via the pure
// TagHistorizeConfig seam so the typed editor's driver-specific keys are preserved. Distinct from the
// native-alarm "Historize to AVEVA" opt-out above (that gates alarm-transition history, not tag values).
private void OnHistorizeChanged(ChangeEventArgs e)
{
var isHistorized = e.Value is true;
_form.TagConfig = TagHistorizeConfig.Set(_form.TagConfig, isHistorized, _historizeState.HistorianTagname);
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
}
// Merge the optional historian-tagname override (root `historianTagname`) into the raw TagConfig.
private void OnHistorianTagnameChanged(ChangeEventArgs e)
{
var tagname = e.Value?.ToString() ?? string.Empty;
_form.TagConfig = TagHistorizeConfig.Set(_form.TagConfig, _historizeState.IsHistorized, tagname);
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
}
// Toggle the root `isArray` array-shape intent in the raw TagConfig, merged via the pure TagArrayConfig
// seam so the typed editor's driver-specific keys are preserved. Clearing it drops `arrayLength` too
// (no orphan length), so the carried _arrayState.ArrayLength is irrelevant when unchecking.
private void OnIsArrayChanged(ChangeEventArgs e)
{
var isArray = e.Value is true;
_form.TagConfig = TagArrayConfig.Set(_form.TagConfig, isArray, _arrayState.ArrayLength);
_arrayState = TagArrayConfig.Read(_form.TagConfig);
}
// Merge the array length (root `arrayLength`) into the raw TagConfig. A blank/zero/negative/non-numeric
// entry parses to null, so the key is dropped until a positive length is typed (and SaveAsync rejects
// an array with no positive length).
private void OnArrayLengthChanged(ChangeEventArgs e)
{
var length = ParsePositiveLength(e.Value?.ToString());
_form.TagConfig = TagArrayConfig.Set(_form.TagConfig, _arrayState.IsArray, length);
_arrayState = TagArrayConfig.Read(_form.TagConfig);
}
// Parse the numeric-input string to a positive uint, or null for blank/zero/negative/overflow/non-numeric.
private static uint? ParsePositiveLength(string? raw)
=> uint.TryParse(raw, out var u) && u > 0 ? u : null;
protected override void OnParametersSet()
{
// Rebuild the working form whenever the host (re)opens the modal for a fresh target.
if (IsNew)
{
_form = new FormModel();
}
else if (Existing is not null)
{
_form = new FormModel
{
TagId = Existing.TagId,
Name = Existing.Name,
DriverInstanceId = Existing.DriverInstanceId,
DataType = Existing.DataType,
AccessLevel = Existing.AccessLevel,
WriteIdempotent = Existing.WriteIdempotent,
PollGroupId = Existing.PollGroupId,
TagConfig = Existing.TagConfig,
};
}
_error = null;
_showGalaxyPicker = false;
_galaxyPickedIsAlarm = false;
// Seed the picker's working address from any existing {"FullName":"..."} so it opens pre-populated.
_galaxyAddress = ReadFullName(_form.TagConfig);
// Seed the historize controls from any existing root isHistorized/historianTagname keys.
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
// Seed the array controls from any existing root isArray/arrayLength keys.
_arrayState = TagArrayConfig.Read(_form.TagConfig);
}
// Best-effort extraction of FullName from a Galaxy TagConfig; returns "" when absent or unparseable.
private static string ReadFullName(string? configJson)
{
if (string.IsNullOrWhiteSpace(configJson)) return "";
try
{
using var doc = JsonDocument.Parse(configJson);
return doc.RootElement.ValueKind == JsonValueKind.Object
&& doc.RootElement.TryGetProperty("FullName", out var fn)
&& fn.ValueKind == JsonValueKind.String
? fn.GetString() ?? ""
: "";
}
catch (JsonException)
{
return "";
}
}
private async Task SaveAsync()
{
_busy = true;
_error = null;
try
{
// Client-side per-driver config validation (the typed editor's Validate()), so a blank
// required field is caught here rather than silently saving and failing at deploy/connect.
var configError = TagConfigValidator.Validate(SelectedDriverType, _form.TagConfig);
if (configError is not null)
{
_error = configError;
return;
}
// Driver-agnostic array-shape validation: an array tag needs a positive length. Mirrors the
// per-driver config validation above so a missing length is caught here rather than at deploy.
var arrayError = TagArrayConfig.Validate(_arrayState.IsArray, _arrayState.ArrayLength);
if (arrayError is not null)
{
_error = arrayError;
return;
}
var input = new TagInput(
_form.TagId,
_form.Name,
_form.DriverInstanceId,
_form.DataType,
_form.AccessLevel,
_form.WriteIdempotent,
_form.PollGroupId,
_form.TagConfig);
var result = IsNew
? await Svc.CreateTagAsync(EquipmentId!, input)
: await Svc.UpdateTagAsync(Existing!.TagId, input, Existing.RowVersion);
if (result.Ok)
{
await OnSaved.InvokeAsync();
}
else
{
_error = result.Error;
}
}
finally
{
_busy = false;
}
}
private Task CancelAsync() => OnCancel.InvokeAsync();
private sealed class FormModel
{
[Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string TagId { get; set; } = "";
[Required] public string Name { get; set; } = "";
[Required] public string DriverInstanceId { get; set; } = "";
public string DataType { get; set; } = "Float";
public TagAccessLevel AccessLevel { get; set; } = TagAccessLevel.Read;
public bool WriteIdempotent { get; set; }
public string? PollGroupId { get; set; }
[Required] public string TagConfig { get; set; } = "{}";
}
}
@@ -99,7 +99,7 @@
Editing shared script "<span class="mono">@_scriptName</span>" — used by Editing shared script "<span class="mono">@_scriptName</span>" — used by
@_scriptUsageCount virtual tag(s). Changes affect all of them. @_scriptUsageCount virtual tag(s). Changes affect all of them.
</div> </div>
<MonacoEditor @bind-Value="_scriptSource" Height="300px" /> <MonacoEditor @bind-Value="_scriptSource" Height="300px" EquipmentId="@EquipmentId" />
@if (!string.IsNullOrWhiteSpace(_scriptError)) @if (!string.IsNullOrWhiteSpace(_scriptError))
{ {
<div class="text-danger small mt-2">@_scriptError</div> <div class="text-danger small mt-2">@_scriptError</div>
@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web; 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.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser; using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
using ZB.MOM.WW.Secrets.Ui;
namespace ZB.MOM.WW.OtOpcUa.AdminUI; 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 // Razor class library static assets (_content/ZB.MOM.WW.OtOpcUa.AdminUI/**) are
// served via the Host's app.UseStaticFiles() middleware which must run BEFORE // served via the Host's app.UseStaticFiles() middleware which must run BEFORE
// UseAuthentication() — see Program.cs. // 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>() app.MapRazorComponents<TApp>()
.AddInteractiveServerRenderMode(); .AddInteractiveServerRenderMode()
.AddAdditionalAssemblies(typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly);
return app; return app;
} }
@@ -43,12 +50,25 @@ public static class EndpointRouteBuilderExtensions
services.AddRazorComponents().AddInteractiveServerComponents(); services.AddRazorComponents().AddInteractiveServerComponents();
services.AddOtOpcUaDriverStatusServices(); 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 // Browse pipeline — see docs/plans/2026-05-28-driver-browsers-design.md
services.AddSingleton<Browsing.BrowseSessionRegistry>(); services.AddSingleton<Browsing.BrowseSessionRegistry>();
services.AddHostedService<Browsing.BrowseSessionReaper>(); services.AddHostedService<Browsing.BrowseSessionReaper>();
services.AddScoped<Browsing.IBrowserSessionService, Browsing.BrowserSessionService>(); services.AddScoped<Browsing.IBrowserSessionService, Browsing.BrowserSessionService>();
services.AddScoped<IUnsTreeService, UnsTreeService>(); services.AddScoped<IUnsTreeService, UnsTreeService>();
services.AddScoped<IRawTreeService, RawTreeService>(); services.AddScoped<IRawTreeService, RawTreeService>();
// v3 Batch 3 (WP2): authoring-time UNS effective-name uniqueness guard. Consumed by
// UnsTreeService's reference/VirtualTag/ScriptedAlarm mutations (WP1); mirrors the
// deploy-time DraftValidator UnsEffectiveNameCollision rule.
services.AddScoped<IEffectiveNameGuard, EffectiveNameGuard>();
// WP5 CSV export enumeration (read-only; does not extend the closed IRawTreeService prelude). // WP5 CSV export enumeration (read-only; does not extend the closed IRawTreeService prelude).
services.AddScoped<RawTagCsvExportReader>(); services.AddScoped<RawTagCsvExportReader>();
services.AddSingleton<IDriverBrowser, OpcUaClientDriverBrowser>(); services.AddSingleton<IDriverBrowser, OpcUaClientDriverBrowser>();
@@ -23,13 +23,16 @@ public interface IScriptTagCatalog
/// <returns>The resolved tag info, or <see langword="null"/> when <paramref name="path"/> is not a known configured path.</returns> /// <returns>The resolved tag info, or <see langword="null"/> when <paramref name="path"/> is not a known configured path.</returns>
Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct); Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct);
/// <summary>Distinct attribute leaf names — the substring after the first dot of each /// <summary>v3: the owning equipment's <c>UnsTagReference</c> effective names (its
/// configured tag FullName — optionally prefix-filtered. Powers {{equip}}. completion, /// <c>DisplayNameOverride</c> else the backing raw tag's <c>Name</c>) — the resolvable set for
/// which needs the per-equipment object prefix stripped.</summary> /// <c>{{equip}}/&lt;RefName&gt;</c> completion + diagnostics, matching what the compose seams substitute
/// <param name="filter">Case-insensitive StartsWith prefix over the leaf; null/empty = all (bounded).</param> /// and the deploy gate enforces. Optionally prefix-filtered. Empty when the equipment id is blank or has
/// no references.</summary>
/// <param name="equipmentId">The equipment whose references to list; blank returns empty.</param>
/// <param name="filter">Case-insensitive StartsWith prefix over the reference name; null/empty = all (bounded).</param>
/// <param name="ct">Cancellation token.</param> /// <param name="ct">Cancellation token.</param>
/// <returns>Distinct leaf names.</returns> /// <returns>Distinct reference effective names.</returns>
Task<IReadOnlyList<string>> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct); Task<IReadOnlyList<string>> GetEquipmentReferenceNamesAsync(string equipmentId, string? filter, CancellationToken ct);
} }
/// <summary>Resolved info for one configured tag/virtual-tag path (for hover).</summary> /// <summary>Resolved info for one configured tag/virtual-tag path (for hover).</summary>
@@ -114,17 +117,32 @@ public sealed class ScriptTagCatalog(IDbContextFactory<OtOpcUaConfigDbContext> d
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<string>> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct) public async Task<IReadOnlyList<string>> GetEquipmentReferenceNamesAsync(string equipmentId, string? filter, CancellationToken ct)
{ {
var entries = await BuildEntriesAsync(ct); if (string.IsNullOrEmpty(equipmentId)) return Array.Empty<string>();
var leaves = new HashSet<string>(StringComparer.Ordinal); await using var db = await dbFactory.CreateDbContextAsync(ct);
foreach (var e in entries)
var refs = await db.UnsTagReferences.AsNoTracking()
.Where(r => r.EquipmentId == equipmentId)
.Select(r => new { r.TagId, r.DisplayNameOverride })
.ToListAsync(ct);
if (refs.Count == 0) return Array.Empty<string>();
var tagIds = refs.Select(r => r.TagId).Distinct().ToList();
var tagNameById = await db.Tags.AsNoTracking()
.Where(t => tagIds.Contains(t.TagId))
.Select(t => new { t.TagId, t.Name })
.ToDictionaryAsync(t => t.TagId, t => t.Name, ct);
// Effective name = DisplayNameOverride else the backing raw tag's Name (ordinal set).
var names = new HashSet<string>(StringComparer.Ordinal);
foreach (var r in refs)
{ {
var dot = e.Path.IndexOf('.'); var eff = r.DisplayNameOverride ?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null);
if (dot < 0 || dot + 1 >= e.Path.Length) continue; if (!string.IsNullOrEmpty(eff)) names.Add(eff!);
leaves.Add(e.Path.Substring(dot + 1));
} }
IEnumerable<string> q = leaves;
IEnumerable<string> q = names;
if (!string.IsNullOrWhiteSpace(filter)) if (!string.IsNullOrWhiteSpace(filter))
q = q.Where(n => n.StartsWith(filter, StringComparison.OrdinalIgnoreCase)); q = q.Where(n => n.StartsWith(filter, StringComparison.OrdinalIgnoreCase));
return q.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).Take(MaxResults).ToList(); return q.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).Take(MaxResults).ToList();
@@ -1,15 +1,19 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis; namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
public record DiagnoseRequest(string Code); // EquipmentId (optional) carries the owning-equipment context the {{equip}}/<RefName> completion +
// diagnostic need to resolve reference effective names. Null on the shared ScriptEdit page (no single
// equipment) — the equip-ref features are then inert, matching the deploy gate (which only resolves per
// owning equipment).
public record DiagnoseRequest(string Code, string? EquipmentId = null);
public record DiagnoseResponse(IReadOnlyList<DiagnosticMarker> Markers); public record DiagnoseResponse(IReadOnlyList<DiagnosticMarker> Markers);
public record DiagnosticMarker(int Severity, int StartLineNumber, int StartColumn, public record DiagnosticMarker(int Severity, int StartLineNumber, int StartColumn,
int EndLineNumber, int EndColumn, string Message, string Code); int EndLineNumber, int EndColumn, string Message, string Code);
public record CompletionsRequest(string CodeText, int Line, int Column); public record CompletionsRequest(string CodeText, int Line, int Column, string? EquipmentId = null);
public record CompletionsResponse(IReadOnlyList<CompletionItem> Items); public record CompletionsResponse(IReadOnlyList<CompletionItem> Items);
public record CompletionItem(string Label, string InsertText, string Detail, string Kind, int InsertTextRules = 0); public record CompletionItem(string Label, string InsertText, string Detail, string Kind, int InsertTextRules = 0);
public record HoverRequest(string CodeText, int Line, int Column); public record HoverRequest(string CodeText, int Line, int Column, string? EquipmentId = null);
public record HoverResponse(string? Markdown); public record HoverResponse(string? Markdown);
public record SignatureHelpRequest(string CodeText, int Line, int Column); public record SignatureHelpRequest(string CodeText, int Line, int Column);
@@ -16,7 +16,7 @@ public static class ScriptAnalysisEndpoints
// ConfigEditor policy (Administrator or Designer) — matches the Script page gate and the /deployments gate. // ConfigEditor policy (Administrator or Designer) — matches the Script page gate and the /deployments gate.
var group = endpoints.MapGroup("/api/script-analysis") var group = endpoints.MapGroup("/api/script-analysis")
.RequireAuthorization(AdminUiPolicies.ConfigEditor); .RequireAuthorization(AdminUiPolicies.ConfigEditor);
group.MapPost("/diagnostics", (DiagnoseRequest r, ScriptAnalysisService s) => Results.Ok(s.Diagnose(r))); group.MapPost("/diagnostics", async (DiagnoseRequest r, ScriptAnalysisService s) => Results.Ok(await s.DiagnoseAsync(r)));
group.MapPost("/completions", async (CompletionsRequest r, ScriptAnalysisService s) => Results.Ok(await s.CompleteAsync(r))); group.MapPost("/completions", async (CompletionsRequest r, ScriptAnalysisService s) => Results.Ok(await s.CompleteAsync(r)));
group.MapPost("/hover", async (HoverRequest r, ScriptAnalysisService s) => Results.Ok(await s.Hover(r))); group.MapPost("/hover", async (HoverRequest r, ScriptAnalysisService s) => Results.Ok(await s.Hover(r)));
group.MapPost("/signature-help", (SignatureHelpRequest r, ScriptAnalysisService s) => Results.Ok(s.SignatureHelp(r))); group.MapPost("/signature-help", (SignatureHelpRequest r, ScriptAnalysisService s) => Results.Ok(s.SignatureHelp(r)));
@@ -130,6 +130,58 @@ public sealed class ScriptAnalysisService
} }
} }
// ctx.GetTag("…") / ctx.SetVirtualTag("…") first-arg literal — three-part capture, mirroring
// EquipmentScriptPaths.PathLiteralRegex so the editor's {{equip}}/<RefName> diagnostic is scoped to the
// SAME path literals the compose seams substitute (never a comment / logger string).
private static readonly System.Text.RegularExpressions.Regex EquipPathLiteralRegex =
new(@"(ctx\s*\.\s*(?:GetTag|SetVirtualTag)\s*\(\s*"")([^""]*)("")",
System.Text.RegularExpressions.RegexOptions.Compiled);
/// <summary>Diagnostics plus the v3 equipment-relative <c>{{equip}}/&lt;RefName&gt;</c> resolution check.
/// Runs the synchronous Roslyn/policy <see cref="Diagnose"/> then, when an equipment context + catalog
/// are present, appends a marker for each <c>{{equip}}/&lt;RefName&gt;</c> whose <c>&lt;RefName&gt;</c> is
/// not one of the equipment's reference effective names — flagging exactly what the deploy gate
/// (<c>DraftValidator.ValidateEquipReferenceResolution</c>) rejects, so the editor accepts ⇔ publish
/// accepts. Inert (base markers only) on the shared ScriptEdit page where <c>EquipmentId</c> is null.</summary>
/// <param name="req">The diagnose request (its <c>EquipmentId</c> carries the owning-equipment context).</param>
/// <returns>The base diagnostics plus any unresolved-reference markers.</returns>
public async Task<DiagnoseResponse> DiagnoseAsync(DiagnoseRequest req)
{
var baseResp = Diagnose(req);
if (_catalog is null || string.IsNullOrEmpty(req.EquipmentId) || string.IsNullOrEmpty(req.Code))
return baseResp;
try
{
var code = Normalize(req.Code);
if (!code.Contains(EquipmentScriptPaths.EquipTokenPrefix, StringComparison.Ordinal))
return baseResp;
var names = await _catalog.GetEquipmentReferenceNamesAsync(req.EquipmentId, null, CancellationToken.None);
var resolvable = new HashSet<string>(names, StringComparer.Ordinal);
var markers = new List<DiagnosticMarker>(baseResp.Markers);
foreach (System.Text.RegularExpressions.Match m in EquipPathLiteralRegex.Matches(code))
{
var content = m.Groups[2].Value;
if (!content.StartsWith(EquipmentScriptPaths.EquipTokenPrefix, StringComparison.Ordinal)) continue;
var refName = content.Substring(EquipmentScriptPaths.EquipTokenPrefix.Length);
if (refName.Length == 0 || resolvable.Contains(refName)) continue;
// Mark the whole literal content span (the {{equip}}/<RefName> path).
var span = new Microsoft.CodeAnalysis.Text.TextSpan(m.Groups[2].Index, content.Length);
markers.Add(ToUserMarker(code, span,
$"'{EquipmentScriptPaths.EquipTokenPrefix}{refName}' does not resolve — this equipment has no " +
$"reference named '{refName}'. Add a matching reference or correct the path.",
"OTSCRIPT_EQUIPREF"));
}
return new DiagnoseResponse(markers.Distinct().ToList());
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Script equip-ref diagnostics failed; returning base markers.");
return baseResp;
}
}
private static int Sev(DiagnosticSeverity s) => s switch private static int Sev(DiagnosticSeverity s) => s switch
{ {
DiagnosticSeverity.Error => 8, DiagnosticSeverity.Error => 8,
@@ -173,13 +225,17 @@ public sealed class ScriptAnalysisService
if (_catalog != null && TryGetTagPathLiteral(token, out var pathPrefix, out _)) if (_catalog != null && TryGetTagPathLiteral(token, out var pathPrefix, out _))
{ {
const string equipDot = EquipmentScriptPaths.EquipToken + "."; // "{{equip}}." // v3: {{equip}}/<RefName> completes the owning equipment's UnsTagReference effective names
if (pathPrefix.StartsWith(equipDot, StringComparison.Ordinal)) // (needs the equipment context — inert on the shared ScriptEdit page where EquipmentId is null).
const string equipPrefix = EquipmentScriptPaths.EquipTokenPrefix; // "{{equip}}/"
if (pathPrefix.StartsWith(equipPrefix, StringComparison.Ordinal))
{ {
var leaves = await _catalog.GetEquipmentRelativeLeavesAsync( if (string.IsNullOrEmpty(req.EquipmentId))
pathPrefix.Substring(equipDot.Length), CancellationToken.None); return new CompletionsResponse(Array.Empty<CompletionItem>());
return new CompletionsResponse(leaves var names = await _catalog.GetEquipmentReferenceNamesAsync(
.Select(n => new CompletionItem(equipDot + n, equipDot + n, "tag path", "Field")) req.EquipmentId, pathPrefix.Substring(equipPrefix.Length), CancellationToken.None);
return new CompletionsResponse(names
.Select(n => new CompletionItem(equipPrefix + n, equipPrefix + n, "tag path", "Field"))
.ToList()); .ToList());
} }
@@ -281,7 +337,8 @@ public sealed class ScriptAnalysisService
{ {
return new HoverResponse( return new HoverResponse(
$"**Equipment-relative path** `{Code(tagPath)}`\n\n" + $"**Equipment-relative path** `{Code(tagPath)}`\n\n" +
"The {{equip}} token is replaced with the owning equipment's tag base when the VirtualTag is deployed."); "`{{equip}}/<RefName>` resolves through the owning equipment's tag reference " +
"(by effective name) to the backing raw tag's path when the VirtualTag is deployed.");
} }
var info = await _catalog.GetTagInfoAsync(tagPath, CancellationToken.None); var info = await _catalog.GetTagInfoAsync(tagPath, CancellationToken.None);
@@ -0,0 +1,106 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>
/// Default <see cref="IEffectiveNameGuard"/>. Loads the owning equipment's current effective-name
/// set — references (<c>DisplayNameOverride</c> else the backing raw tag's <c>Name</c>),
/// VirtualTags (<c>Name</c>), and ScriptedAlarms (<c>Name</c>) — and reports the first collision
/// with a proposed name. Comparison is <see cref="StringComparer.Ordinal"/> to match the
/// <c>{EquipmentId}/{EffectiveName}</c> NodeId semantics and the deploy-time
/// <c>DraftValidator</c> <c>UnsEffectiveNameCollision</c> rule.
/// </summary>
/// <remarks>
/// Each call creates and disposes its own pooled context — the same per-call pattern
/// <see cref="UnsTreeService"/> uses — so the guard is safe to register <c>Scoped</c>.
/// </remarks>
public sealed class EffectiveNameGuard(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory) : IEffectiveNameGuard
{
/// <inheritdoc />
public async Task<string?> CheckAsync(
string equipmentId,
string proposedEffectiveName,
EffectiveNameSourceKind kind,
string? excludeSourceId,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
// References for this equipment. The effective name is the display-name override else the
// backing raw tag's current Name, so the two sets (references + tags) are loaded and joined
// in memory — the per-equipment set is small and this mirrors the deploy rule exactly
// (a reference whose backing tag is missing contributes no effective name, so it is skipped).
var references = await db.UnsTagReferences
.AsNoTracking()
.Where(r => r.EquipmentId == equipmentId)
.Select(r => new { r.UnsTagReferenceId, r.TagId, r.DisplayNameOverride })
.ToListAsync(ct);
var tagIds = references.Select(r => r.TagId).Distinct(StringComparer.Ordinal).ToList();
var tagNameById = (await db.Tags
.AsNoTracking()
.Where(t => tagIds.Contains(t.TagId))
.Select(t => new { t.TagId, t.Name })
.ToListAsync(ct))
.GroupBy(t => t.TagId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().Name, StringComparer.Ordinal);
foreach (var r in references)
{
if (IsSelf(EffectiveNameSourceKind.Reference, r.UnsTagReferenceId, kind, excludeSourceId))
continue;
var effective = r.DisplayNameOverride
?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null);
if (Collides(effective, proposedEffectiveName))
return Message(proposedEffectiveName, "reference", r.UnsTagReferenceId, equipmentId);
}
var virtualTags = await db.VirtualTags
.AsNoTracking()
.Where(v => v.EquipmentId == equipmentId)
.Select(v => new { v.VirtualTagId, v.Name })
.ToListAsync(ct);
foreach (var v in virtualTags)
{
if (IsSelf(EffectiveNameSourceKind.VirtualTag, v.VirtualTagId, kind, excludeSourceId))
continue;
if (Collides(v.Name, proposedEffectiveName))
return Message(proposedEffectiveName, "VirtualTag", v.VirtualTagId, equipmentId);
}
var scriptedAlarms = await db.ScriptedAlarms
.AsNoTracking()
.Where(a => a.EquipmentId == equipmentId)
.Select(a => new { a.ScriptedAlarmId, a.Name })
.ToListAsync(ct);
foreach (var a in scriptedAlarms)
{
if (IsSelf(EffectiveNameSourceKind.ScriptedAlarm, a.ScriptedAlarmId, kind, excludeSourceId))
continue;
if (Collides(a.Name, proposedEffectiveName))
return Message(proposedEffectiveName, "ScriptedAlarm", a.ScriptedAlarmId, equipmentId);
}
return null;
}
/// <summary>True when a row is the self-row being edited — same kind AND same logical id — so it
/// is excluded from the collision set (a rename / override-change of a row cannot collide with itself).</summary>
private static bool IsSelf(
EffectiveNameSourceKind rowKind,
string rowId,
EffectiveNameSourceKind editedKind,
string? excludeSourceId) =>
excludeSourceId is not null
&& rowKind == editedKind
&& string.Equals(rowId, excludeSourceId, StringComparison.Ordinal);
private static bool Collides(string? existing, string proposed) =>
existing is not null && string.Equals(existing, proposed, StringComparison.Ordinal);
private static string Message(string name, string sourceLabel, string sourceId, string equipmentId) =>
$"effective name '{name}' already used by {sourceLabel} '{sourceId}' in equipment '{equipmentId}'";
}
@@ -9,3 +9,28 @@ public sealed record EquipmentTagRow(
/// <summary>A virtual-tag row for the equipment page's Virtual Tags tab table.</summary> /// <summary>A virtual-tag row for the equipment page's Virtual Tags tab table.</summary>
public sealed record EquipmentVirtualTagRow(string VirtualTagId, string Name, string DataType, string ScriptId, bool Enabled); public sealed record EquipmentVirtualTagRow(string VirtualTagId, string Name, string DataType, string ScriptId, bool Enabled);
/// <summary>
/// A UNS tag-reference row for the equipment page's Tags tab table (v3 reference-only equipment). Each
/// row projects a <see cref="Configuration.Entities.UnsTagReference"/> together with the backing raw
/// tag's inherited (read-only) datatype/access and computed RawPath. The effective name is the
/// <c>DisplayNameOverride</c> when set, else the backing raw tag's <c>Name</c>. <c>RowVersion</c> is the
/// reference row's concurrency token, echoed on the override-set / remove mutations.
/// </summary>
/// <param name="UnsTagReferenceId">The reference's stable logical id (the mutation key).</param>
/// <param name="EffectiveName">Override else the raw tag's Name — the UNS leaf name.</param>
/// <param name="RawPath">The backing raw tag's computed RawPath (folder/driver/device/group/tag).</param>
/// <param name="DataType">Inherited OPC UA built-in type name from the raw tag (read-only).</param>
/// <param name="AccessLevel">Inherited access level from the raw tag (read-only).</param>
/// <param name="DisplayNameOverride">The optional display-name override; <c>null</c> = use the raw name.</param>
/// <param name="SortOrder">Sibling display ordering within the equipment's Tags list.</param>
/// <param name="RowVersion">The reference row's optimistic-concurrency token.</param>
public sealed record EquipmentReferenceRow(
string UnsTagReferenceId,
string EffectiveName,
string RawPath,
string DataType,
TagAccessLevel AccessLevel,
string? DisplayNameOverride,
int SortOrder,
byte[] RowVersion);
@@ -11,7 +11,6 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <param name="Name">UNS level-5 segment; matches <c>^[a-z0-9-]{1,32}$</c>.</param> /// <param name="Name">UNS level-5 segment; matches <c>^[a-z0-9-]{1,32}$</c>.</param>
/// <param name="MachineCode">Operator colloquial id; unique fleet-wide.</param> /// <param name="MachineCode">Operator colloquial id; unique fleet-wide.</param>
/// <param name="UnsLineId">Logical FK to the owning <see cref="Configuration.Entities.UnsLine"/>.</param> /// <param name="UnsLineId">Logical FK to the owning <see cref="Configuration.Entities.UnsLine"/>.</param>
/// <param name="DriverInstanceId">Optional driver binding; whitespace/empty means driver-less.</param>
/// <param name="ZTag">Optional ERP equipment id.</param> /// <param name="ZTag">Optional ERP equipment id.</param>
/// <param name="SAPID">Optional SAP PM equipment id.</param> /// <param name="SAPID">Optional SAP PM equipment id.</param>
/// <param name="Manufacturer">Optional OPC 40010 manufacturer name.</param> /// <param name="Manufacturer">Optional OPC 40010 manufacturer name.</param>
@@ -28,7 +27,6 @@ public sealed record EquipmentInput(
string Name, string Name,
string MachineCode, string MachineCode,
string UnsLineId, string UnsLineId,
string? DriverInstanceId,
string? ZTag, string? ZTag,
string? SAPID, string? SAPID,
string? Manufacturer, string? Manufacturer,
@@ -0,0 +1,61 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>
/// Which authoring surface a proposed effective name comes from. Used to word the collision
/// error and to exclude the correct same-kind self-row on an edit (rename / override change).
/// </summary>
public enum EffectiveNameSourceKind
{
/// <summary>A <see cref="Configuration.Entities.UnsTagReference"/> (effective name =
/// <c>DisplayNameOverride</c> else the backing raw tag's <c>Name</c>).</summary>
Reference,
/// <summary>An equipment-bound <see cref="Configuration.Entities.VirtualTag"/> (effective name = <c>Name</c>).</summary>
VirtualTag,
/// <summary>An equipment-bound <see cref="Configuration.Entities.ScriptedAlarm"/> (effective name = <c>Name</c>).</summary>
ScriptedAlarm,
}
/// <summary>
/// v3 Batch 3 authoring-time guard for the UNS effective-name uniqueness rule. Within an
/// equipment the EFFECTIVE leaf name must be unique across its <c>UnsTagReferences</c>
/// (<c>DisplayNameOverride</c> else the backing raw tag's <c>Name</c>), its <c>VirtualTags</c>
/// (<c>Name</c>), and its <c>ScriptedAlarms</c> (<c>Name</c>) — the three share the equipment's
/// UNS NodeId space (<c>{EquipmentId}/{EffectiveName}</c>). This is the authoring mirror of the
/// deploy-time <c>DraftValidator</c> <c>UnsEffectiveNameCollision</c> rule; comparison is
/// <see cref="StringComparer.Ordinal"/> to match NodeId semantics and the validator.
/// </summary>
/// <remarks>
/// Injectable (registered <c>Scoped</c>, its own pooled context per call — the same pattern
/// <c>UnsTreeService</c> uses). Consumed by <c>UnsTreeService</c>'s reference / virtual-tag /
/// scripted-alarm mutations, which call <see cref="CheckAsync"/> before persisting and surface a
/// non-null result as the mutation's readable failure. The deploy gate remains the backstop for
/// rename-induced collisions the authoring check never saw.
/// </remarks>
public interface IEffectiveNameGuard
{
/// <summary>
/// Tests whether <paramref name="proposedEffectiveName"/> would collide with an existing
/// effective name within <paramref name="equipmentId"/>.
/// </summary>
/// <param name="equipmentId">The owning equipment's logical id.</param>
/// <param name="proposedEffectiveName">The effective name to be authored (override else raw name for a reference; Name for a VT/alarm).</param>
/// <param name="kind">Which surface the proposed name is for (words the error; identifies the self-row kind to exclude).</param>
/// <param name="excludeSourceId">
/// On an edit, the logical id of the row being changed (its <c>UnsTagReferenceId</c> /
/// <c>VirtualTagId</c> / <c>ScriptedAlarmId</c>) so it is excluded from the collision set;
/// <see langword="null"/> on a create.
/// </param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// <see langword="null"/> when the name is free; otherwise a readable error naming the
/// colliding existing source and the equipment.
/// </returns>
Task<string?> CheckAsync(
string equipmentId,
string proposedEffectiveName,
EffectiveNameSourceKind kind,
string? excludeSourceId,
CancellationToken ct = default);
}
@@ -139,6 +139,82 @@ public interface IUnsTreeService
/// <returns>The equipment's virtual-tag rows ordered by Name; empty if it has none.</returns> /// <returns>The equipment's virtual-tag rows ordered by Name; empty if it has none.</returns>
Task<IReadOnlyList<EquipmentVirtualTagRow>> LoadVirtualTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default); Task<IReadOnlyList<EquipmentVirtualTagRow>> LoadVirtualTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default);
// ---------------------------------------------------------------------------------------------
// v3 UNS reference-only equipment: the Tags tab lists UnsTagReference rows pointing at raw tags.
// ---------------------------------------------------------------------------------------------
/// <summary>
/// Loads the <see cref="Configuration.Entities.UnsTagReference"/> rows for a single equipment as flat
/// projections for the equipment page's Tags tab, ordered by <c>SortOrder</c> then effective name.
/// Each row carries the effective name (override else the backing raw tag's <c>Name</c>), the backing
/// tag's computed RawPath, its inherited (read-only) datatype/access, the optional display-name
/// override, and the reference's concurrency token. Reads untracked. Returns an empty list when the
/// equipment has no references.
/// </summary>
/// <param name="equipmentId">The equipment whose references to load.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The equipment's reference rows; empty if it has none.</returns>
Task<IReadOnlyList<EquipmentReferenceRow>> LoadReferencesForEquipmentAsync(string equipmentId, CancellationToken ct = default);
/// <summary>
/// Adds one <see cref="Configuration.Entities.UnsTagReference"/> per supplied raw <c>TagId</c> to an
/// equipment. Every backing tag MUST live in the same cluster as the equipment (cross-cluster
/// references are rejected). Each new reference's effective name (the raw tag's <c>Name</c> — no
/// override on add) must be unique within the equipment across references, VirtualTags, and
/// ScriptedAlarms (enforced via <see cref="IEffectiveNameGuard"/>), and a tag already referenced by
/// the equipment is rejected. The batch is all-or-nothing.
/// </summary>
/// <param name="equipmentId">The referencing equipment.</param>
/// <param name="tagIds">The raw tag ids to reference.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, or the first guard/cluster/duplicate failure (nothing is added).</returns>
Task<UnsMutationResult> AddReferencesAsync(string equipmentId, IReadOnlyList<string> tagIds, CancellationToken ct = default);
/// <summary>
/// Removes a UNS tag reference. A missing row is treated as success (already gone). Uses last-write-wins
/// optimistic concurrency on <see cref="Configuration.Entities.UnsTagReference.RowVersion"/>.
/// </summary>
/// <param name="unsTagReferenceId">The reference to remove.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a concurrency failure, or a delete-failed failure.</returns>
Task<UnsMutationResult> RemoveReferenceAsync(string unsTagReferenceId, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Sets (or clears) a reference's <c>DisplayNameOverride</c>. A whitespace-only override collapses to
/// <c>null</c> (use the raw name). The resulting effective name (override else the backing raw tag's
/// <c>Name</c>) must be unique within the equipment across references, VirtualTags, and ScriptedAlarms
/// (enforced via <see cref="IEffectiveNameGuard"/>, excluding this reference). Uses last-write-wins
/// optimistic concurrency.
/// </summary>
/// <param name="unsTagReferenceId">The reference to update.</param>
/// <param name="displayNameOverride">The new override, or <c>null</c>/blank to clear it.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a missing-row failure, a name-collision failure, or a concurrency failure.</returns>
Task<UnsMutationResult> SetReferenceOverrideAsync(string unsTagReferenceId, string? displayNameOverride, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Builds the single cluster-rooted <see cref="RawNode"/> that seeds the "+ Add reference" raw-tag
/// picker for an equipment, structurally scoped to the equipment's own cluster (raw tags in any other
/// cluster are unreachable through this root — the scope is enforced by the query, not merely hidden).
/// The picker's <c>RawTree</c> lazily expands it via <see cref="IRawTreeService.LoadChildrenAsync"/>.
/// Returns <c>null</c> when the equipment cannot be resolved to a cluster.
/// </summary>
/// <param name="equipmentId">The equipment whose cluster scopes the picker.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The cluster root node, or <c>null</c> when the equipment/cluster can't be resolved.</returns>
Task<RawNode?> LoadReferencePickerRootAsync(string equipmentId, CancellationToken ct = default);
/// <summary>
/// Enumerates every raw <c>TagId</c> beneath a Device or TagGroup picker node (the whole subtree), for
/// the picker's "select all tags below" affordance. Returns an empty list for non-container node kinds.
/// </summary>
/// <param name="node">The picker node (Device or TagGroup) to enumerate tags under.</param>
/// <param name="ct">A token to cancel the query.</param>
/// <returns>The tag ids in the node's subtree; empty for unsupported kinds.</returns>
Task<IReadOnlyList<string>> LoadDescendantTagIdsAsync(RawNode node, CancellationToken ct = default);
/// <summary> /// <summary>
/// Loads a single UNS area projected for editing, or <c>null</c> if it no longer exists. /// Loads a single UNS area projected for editing, or <c>null</c> if it no longer exists.
/// Reads untracked and captures the current concurrency token for last-write-wins saves. /// Reads untracked and captures the current concurrency token for last-write-wins saves.
@@ -284,24 +360,22 @@ public interface IUnsTreeService
/// <summary> /// <summary>
/// Creates a new equipment under a UNS line. The <c>EquipmentId</c> is system-generated /// Creates a new equipment under a UNS line. The <c>EquipmentId</c> is system-generated
/// (<c>EQ-</c> + the first 12 hex chars of a fresh <c>EquipmentUuid</c>). /// (<c>EQ-</c> + the first 12 hex chars of a fresh <c>EquipmentUuid</c>).
/// Fails if the line is unset, if the MachineCode is already used fleet-wide, or if the /// Fails if the line is unset or if the MachineCode is already used fleet-wide. Whitespace-only
/// driver-cluster guard trips. Whitespace-only DriverInstanceId/ZTag/SAPID /// ZTag/SAPID collapse to <c>null</c>. (v3: equipment no longer binds a driver — the reference-only
/// collapse to <c>null</c>. /// Tags tab points at raw tags instead — so no driver-cluster guard applies.)
/// </summary> /// </summary>
/// <param name="input">The operator-editable equipment fields.</param> /// <param name="input">The operator-editable equipment fields.</param>
/// <param name="ct">A token to cancel the operation.</param> /// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a missing-line failure, a duplicate-MachineCode failure, or a cluster-guard failure.</returns> /// <returns>Success, a missing-line failure, or a duplicate-MachineCode failure.</returns>
Task<UnsMutationResult> CreateEquipmentAsync(EquipmentInput input, CancellationToken ct = default); Task<UnsMutationResult> CreateEquipmentAsync(EquipmentInput input, CancellationToken ct = default);
/// <summary> /// <summary>
/// Bulk-imports equipment from a parsed set of <see cref="EquipmentInput"/> rows in a single /// Bulk-imports equipment from a parsed set of <see cref="EquipmentInput"/> rows in a single
/// context, applying the same rules as the single-add path: a row whose <c>UnsLineId</c> does not /// context, applying the same rules as the single-add path: a row whose <c>UnsLineId</c> does not
/// exist is an error; a row whose <c>DriverInstanceId</c> is set but does not resolve is an error; /// exist is an error; and a row whose <c>MachineCode</c> already exists in the DB <em>or</em> earlier
/// a driver-bound row whose driver is in a different cluster than its line fails the cluster /// in the same batch is silently skipped (additive-only — never an update). Inserted rows get a
/// guard; and a row whose <c>MachineCode</c> already exists in the DB <em>or</em> earlier in the
/// same batch is silently skipped (additive-only — never an update). Inserted rows get a
/// system-generated <c>EQ-</c> id and a fresh <c>EquipmentUuid</c>. All inserts are saved once at /// system-generated <c>EQ-</c> id and a fresh <c>EquipmentUuid</c>. All inserts are saved once at
/// the end. /// the end. (v3: equipment no longer binds a driver, so there is no per-row driver-cluster guard.)
/// </summary> /// </summary>
/// <param name="rows">The parsed equipment rows to import.</param> /// <param name="rows">The parsed equipment rows to import.</param>
/// <param name="ct">A token to cancel the operation.</param> /// <param name="ct">A token to cancel the operation.</param>
@@ -309,16 +383,16 @@ public interface IUnsTreeService
Task<EquipmentImportResult> ImportEquipmentAsync(IReadOnlyList<EquipmentInput> rows, CancellationToken ct = default); Task<EquipmentImportResult> ImportEquipmentAsync(IReadOnlyList<EquipmentInput> rows, CancellationToken ct = default);
/// <summary> /// <summary>
/// Updates an equipment's mutable fields (driver binding, line, name, MachineCode, external /// Updates an equipment's mutable fields (line, name, MachineCode, external ids, and the OPC 40010
/// ids, and the OPC 40010 identification fields). The driver-cluster guard blocks /// identification fields). Fails on a MachineCode already used by another row. Uses last-write-wins
/// binding to a driver in a different cluster than the equipment's line. Uses last-write-wins /// optimistic concurrency on <see cref="Configuration.Entities.Equipment.RowVersion"/>. (v3: equipment
/// optimistic concurrency on <see cref="Configuration.Entities.Equipment.RowVersion"/>. /// no longer binds a driver, so there is no driver-cluster guard.)
/// </summary> /// </summary>
/// <param name="equipmentId">The equipment to update.</param> /// <param name="equipmentId">The equipment to update.</param>
/// <param name="input">The new operator-editable equipment fields.</param> /// <param name="input">The new operator-editable equipment fields.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param> /// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param> /// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a missing-row failure, a cluster-guard failure, or a concurrency failure.</returns> /// <returns>Success, a missing-row failure, a duplicate-MachineCode failure, or a concurrency failure.</returns>
Task<UnsMutationResult> UpdateEquipmentAsync(string equipmentId, EquipmentInput input, byte[] rowVersion, CancellationToken ct = default); Task<UnsMutationResult> UpdateEquipmentAsync(string equipmentId, EquipmentInput input, byte[] rowVersion, CancellationToken ct = default);
/// <summary> /// <summary>
@@ -1282,24 +1282,41 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
} }
/// <summary> /// <summary>
/// Builds the non-blocking rename warnings answerable from this batch's schema: historized tags and /// A raw tag affected by a rename, carrying the fields needed to (a) read its platform intents and
/// equipment-referenced tags beneath the renamed node (whose RawPath — and thus historian tagname / /// (b) recompute its OLD RawPath (still the in-DB state at warning time — the rename is not yet saved).
/// UNS projection path — changes with the rename). Batch 3 appends the script-substring scan here. /// </summary>
private readonly record struct AffectedTag(
string TagId, string TagConfig, string DeviceId, string? TagGroupId, string Name);
/// <summary>
/// Builds the non-blocking rename warnings answerable from this batch's schema for the tags beneath the
/// renamed node (whose RawPath — and thus historian tagname / UNS projection path / script literal —
/// changes with the rename): (a) historized tags WITHOUT a <c>historianTagname</c> override (their
/// history forks, since the default tagname is the moving RawPath; pinned tags are unaffected),
/// (b) equipment-referenced tags, and (c) tags whose OLD RawPath appears as a literal substring in any
/// <see cref="Script"/> body (tolerates false positives by design — no AST).
/// </summary> /// </summary>
private static async Task<IReadOnlyList<string>> BuildRenameWarningsAsync( private static async Task<IReadOnlyList<string>> BuildRenameWarningsAsync(
OtOpcUaConfigDbContext db, IReadOnlyList<(string TagId, string TagConfig)> tags, CancellationToken ct) OtOpcUaConfigDbContext db, IReadOnlyList<AffectedTag> tags, CancellationToken ct)
{ {
var warnings = new List<string>(); var warnings = new List<string>();
if (tags.Count == 0) return warnings; if (tags.Count == 0) return warnings;
var historized = tags.Count(t => TagConfigIntent.Parse(t.TagConfig).IsHistorized); // (a) Historized WITHOUT a historianTagname override: the default tagname is the RawPath, which is
if (historized > 0) // moving, so history forks on the next deploy. A tag WITH the override is pinned and does not warn.
var forking = tags.Count(t =>
{ {
warnings.Add(historized == 1 var intent = TagConfigIntent.Parse(t.TagConfig);
? "1 historized tag beneath this node will change its RawPath (historian tagname). Update the historian if it keys on the old path." return intent.IsHistorized && intent.HistorianTagname is null;
: $"{historized} historized tags beneath this node will change their RawPath (historian tagname). Update the historian if it keys on the old paths."); });
if (forking > 0)
{
warnings.Add(forking == 1
? "1 historized tag beneath this node has no historianTagname override; its history forks to a new tagname when its RawPath changes on the next deploy. Set a historianTagname to pin it."
: $"{forking} historized tags beneath this node have no historianTagname override; their history forks to new tagnames when their RawPaths change on the next deploy. Set a historianTagname to pin them.");
} }
// (b) UNS-referenced: name the referencing equipment.
var tagIds = tags.Select(t => t.TagId).ToList(); var tagIds = tags.Select(t => t.TagId).ToList();
var referencingEquipmentIds = await db.UnsTagReferences.AsNoTracking() var referencingEquipmentIds = await db.UnsTagReferences.AsNoTracking()
.Where(r => tagIds.Contains(r.TagId)) .Where(r => tagIds.Contains(r.TagId))
@@ -1313,12 +1330,94 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
warnings.Add($"Tags beneath this node are referenced by equipment {display}; the UNS projection path will change with the rename."); warnings.Add($"Tags beneath this node are referenced by equipment {display}; the UNS projection path will change with the rename.");
} }
// Batch 3 extension point: scan scripts for substrings of the affected tags' RawPaths and append // (c) Script literals: substring-scan every Script body for the affected tags' OLD RawPaths.
// a "N script(s) reference tags beneath this node" warning to this same list. var scriptWarning = await BuildScriptReferenceWarningAsync(db, tags, ct);
if (scriptWarning is not null) warnings.Add(scriptWarning);
return warnings; return warnings;
} }
/// <summary>
/// Scans every <see cref="Script"/> body for the OLD RawPath of any affected tag as a plain ordinal
/// substring (no AST — false positives tolerated by design; <c>{{equip}}</c>-relative refs are NOT
/// scanned, they resolve through references and the deploy gate catches breakage). Returns a warning
/// naming the matched scripts, or <see langword="null"/> when none match.
/// </summary>
private static async Task<string?> BuildScriptReferenceWarningAsync(
OtOpcUaConfigDbContext db, IReadOnlyList<AffectedTag> tags, CancellationToken ct)
{
var oldPaths = await ComputeOldRawPathsAsync(db, tags, ct);
if (oldPaths.Count == 0) return null;
var scripts = await db.Scripts.AsNoTracking()
.Select(s => new { s.ScriptId, s.Name, s.SourceCode })
.ToListAsync(ct);
var matched = scripts
.Where(s => oldPaths.Any(p => s.SourceCode.Contains(p, StringComparison.Ordinal)))
.OrderBy(s => s.Name, StringComparer.Ordinal)
.Select(s => s.Name)
.ToList();
if (matched.Count == 0) return null;
var names = string.Join(", ", matched.Select(n => $"'{n}'"));
return matched.Count == 1
? $"1 script ({names}) references a tag beneath this node by its raw path; the reference will break unless the script is updated."
: $"{matched.Count} scripts ({names}) reference tags beneath this node by their raw paths; the references will break unless the scripts are updated.";
}
/// <summary>
/// Recomputes the OLD RawPath of each affected tag from the current (pre-save) in-DB raw topology,
/// dropping any tag whose ancestry chain is broken (a null RawPath). Loads only the topology the affected
/// tags need: their devices + drivers, the drivers' clusters' folders, and the devices' tag groups.
/// </summary>
private static async Task<IReadOnlyList<string>> ComputeOldRawPathsAsync(
OtOpcUaConfigDbContext db, IReadOnlyList<AffectedTag> tags, CancellationToken ct)
{
var deviceIds = tags.Select(t => t.DeviceId).Distinct(StringComparer.Ordinal).ToList();
var deviceRows = await db.Devices.AsNoTracking()
.Where(d => deviceIds.Contains(d.DeviceId))
.Select(d => new { d.DeviceId, d.DriverInstanceId, d.Name })
.ToListAsync(ct);
var driverIds = deviceRows.Select(d => d.DriverInstanceId).Distinct(StringComparer.Ordinal).ToList();
var driverRows = await db.DriverInstances.AsNoTracking()
.Where(d => driverIds.Contains(d.DriverInstanceId))
.Select(d => new { d.DriverInstanceId, d.RawFolderId, d.Name, d.ClusterId })
.ToListAsync(ct);
var clusterIds = driverRows.Select(d => d.ClusterId).Distinct(StringComparer.Ordinal).ToList();
var folderRows = await db.RawFolders.AsNoTracking()
.Where(f => clusterIds.Contains(f.ClusterId))
.Select(f => new { f.RawFolderId, f.ParentRawFolderId, f.Name })
.ToListAsync(ct);
var groupRows = await db.TagGroups.AsNoTracking()
.Where(g => deviceIds.Contains(g.DeviceId))
.Select(g => new { g.TagGroupId, g.ParentTagGroupId, g.Name })
.ToListAsync(ct);
var folders = folderRows.ToDictionary(
f => f.RawFolderId, f => ((string?)f.ParentRawFolderId, f.Name), StringComparer.Ordinal);
var drivers = driverRows.ToDictionary(
d => d.DriverInstanceId, d => ((string?)d.RawFolderId, d.Name), StringComparer.Ordinal);
var devices = deviceRows.ToDictionary(
d => d.DeviceId, d => (d.DriverInstanceId, d.Name), StringComparer.Ordinal);
var groups = groupRows.ToDictionary(
g => g.TagGroupId, g => ((string?)g.ParentTagGroupId, g.Name), StringComparer.Ordinal);
var resolver = new RawPathResolver(folders, drivers, devices, groups);
var paths = new List<string>(tags.Count);
foreach (var t in tags)
{
var path = resolver.TryBuildTagPath(t.DeviceId, t.TagGroupId, t.Name);
if (path is not null) paths.Add(path);
}
return paths;
}
/// <summary>Renders a friendly "<c>Name (Id)</c>, …" list for the referencing equipment ids.</summary> /// <summary>Renders a friendly "<c>Name (Id)</c>, …" list for the referencing equipment ids.</summary>
private static async Task<string> DescribeEquipmentAsync( private static async Task<string> DescribeEquipmentAsync(
OtOpcUaConfigDbContext db, IReadOnlyList<string> equipmentIds, CancellationToken ct) OtOpcUaConfigDbContext db, IReadOnlyList<string> equipmentIds, CancellationToken ct)
@@ -1336,11 +1435,11 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
// --- Subtree tag collectors (walk the in-DB subtree for rename impact) --- // --- Subtree tag collectors (walk the in-DB subtree for rename impact) ---
private static async Task<IReadOnlyList<(string TagId, string TagConfig)>> CollectTagsBeneathFolderAsync( private static async Task<IReadOnlyList<AffectedTag>> CollectTagsBeneathFolderAsync(
OtOpcUaConfigDbContext db, string rawFolderId, CancellationToken ct) OtOpcUaConfigDbContext db, string rawFolderId, CancellationToken ct)
{ {
var folder = await db.RawFolders.AsNoTracking().FirstOrDefaultAsync(f => f.RawFolderId == rawFolderId, ct); var folder = await db.RawFolders.AsNoTracking().FirstOrDefaultAsync(f => f.RawFolderId == rawFolderId, ct);
if (folder is null) return Array.Empty<(string, string)>(); if (folder is null) return Array.Empty<AffectedTag>();
// All folders in the cluster, walked in-memory to the descendant set (incl. self) via ParentRawFolderId. // All folders in the cluster, walked in-memory to the descendant set (incl. self) via ParentRawFolderId.
var clusterFolders = await db.RawFolders.AsNoTracking() var clusterFolders = await db.RawFolders.AsNoTracking()
@@ -1359,44 +1458,44 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
.Where(d => d.RawFolderId != null && folderIds.Contains(d.RawFolderId)) .Where(d => d.RawFolderId != null && folderIds.Contains(d.RawFolderId))
.Select(d => d.DriverInstanceId) .Select(d => d.DriverInstanceId)
.ToListAsync(ct); .ToListAsync(ct);
if (driverIds.Count == 0) return Array.Empty<(string, string)>(); if (driverIds.Count == 0) return Array.Empty<AffectedTag>();
var deviceIds = await db.Devices.AsNoTracking() var deviceIds = await db.Devices.AsNoTracking()
.Where(dev => driverIds.Contains(dev.DriverInstanceId)) .Where(dev => driverIds.Contains(dev.DriverInstanceId))
.Select(dev => dev.DeviceId) .Select(dev => dev.DeviceId)
.ToListAsync(ct); .ToListAsync(ct);
if (deviceIds.Count == 0) return Array.Empty<(string, string)>(); if (deviceIds.Count == 0) return Array.Empty<AffectedTag>();
return await TagsForDevicesAsync(db, deviceIds, ct); return await TagsForDevicesAsync(db, deviceIds, ct);
} }
private static async Task<IReadOnlyList<(string TagId, string TagConfig)>> CollectTagsBeneathDriverAsync( private static async Task<IReadOnlyList<AffectedTag>> CollectTagsBeneathDriverAsync(
OtOpcUaConfigDbContext db, string driverInstanceId, CancellationToken ct) OtOpcUaConfigDbContext db, string driverInstanceId, CancellationToken ct)
{ {
var deviceIds = await db.Devices.AsNoTracking() var deviceIds = await db.Devices.AsNoTracking()
.Where(dev => dev.DriverInstanceId == driverInstanceId) .Where(dev => dev.DriverInstanceId == driverInstanceId)
.Select(dev => dev.DeviceId) .Select(dev => dev.DeviceId)
.ToListAsync(ct); .ToListAsync(ct);
if (deviceIds.Count == 0) return Array.Empty<(string, string)>(); if (deviceIds.Count == 0) return Array.Empty<AffectedTag>();
return await TagsForDevicesAsync(db, deviceIds, ct); return await TagsForDevicesAsync(db, deviceIds, ct);
} }
private static async Task<IReadOnlyList<(string TagId, string TagConfig)>> CollectTagsBeneathDeviceAsync( private static async Task<IReadOnlyList<AffectedTag>> CollectTagsBeneathDeviceAsync(
OtOpcUaConfigDbContext db, string deviceId, CancellationToken ct) OtOpcUaConfigDbContext db, string deviceId, CancellationToken ct)
{ {
return (await db.Tags.AsNoTracking() return (await db.Tags.AsNoTracking()
.Where(t => t.DeviceId == deviceId) .Where(t => t.DeviceId == deviceId)
.Select(t => new { t.TagId, t.TagConfig }) .Select(t => new { t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name })
.ToListAsync(ct)) .ToListAsync(ct))
.Select(t => (t.TagId, t.TagConfig)) .Select(t => new AffectedTag(t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name))
.ToList(); .ToList();
} }
private static async Task<IReadOnlyList<(string TagId, string TagConfig)>> CollectTagsBeneathGroupAsync( private static async Task<IReadOnlyList<AffectedTag>> CollectTagsBeneathGroupAsync(
OtOpcUaConfigDbContext db, string tagGroupId, CancellationToken ct) OtOpcUaConfigDbContext db, string tagGroupId, CancellationToken ct)
{ {
var group = await db.TagGroups.AsNoTracking().FirstOrDefaultAsync(g => g.TagGroupId == tagGroupId, ct); var group = await db.TagGroups.AsNoTracking().FirstOrDefaultAsync(g => g.TagGroupId == tagGroupId, ct);
if (group is null) return Array.Empty<(string, string)>(); if (group is null) return Array.Empty<AffectedTag>();
var deviceGroups = await db.TagGroups.AsNoTracking() var deviceGroups = await db.TagGroups.AsNoTracking()
.Where(g => g.DeviceId == group.DeviceId) .Where(g => g.DeviceId == group.DeviceId)
@@ -1412,20 +1511,20 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
return (await db.Tags.AsNoTracking() return (await db.Tags.AsNoTracking()
.Where(t => t.TagGroupId != null && groupIds.Contains(t.TagGroupId)) .Where(t => t.TagGroupId != null && groupIds.Contains(t.TagGroupId))
.Select(t => new { t.TagId, t.TagConfig }) .Select(t => new { t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name })
.ToListAsync(ct)) .ToListAsync(ct))
.Select(t => (t.TagId, t.TagConfig)) .Select(t => new AffectedTag(t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name))
.ToList(); .ToList();
} }
private static async Task<IReadOnlyList<(string TagId, string TagConfig)>> TagsForDevicesAsync( private static async Task<IReadOnlyList<AffectedTag>> TagsForDevicesAsync(
OtOpcUaConfigDbContext db, IReadOnlyList<string> deviceIds, CancellationToken ct) OtOpcUaConfigDbContext db, IReadOnlyList<string> deviceIds, CancellationToken ct)
{ {
return (await db.Tags.AsNoTracking() return (await db.Tags.AsNoTracking()
.Where(t => deviceIds.Contains(t.DeviceId)) .Where(t => deviceIds.Contains(t.DeviceId))
.Select(t => new { t.TagId, t.TagConfig }) .Select(t => new { t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name })
.ToListAsync(ct)) .ToListAsync(ct))
.Select(t => (t.TagId, t.TagConfig)) .Select(t => new AffectedTag(t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name))
.ToList(); .ToList();
} }
@@ -18,8 +18,17 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// every AdminUI page uses — so the service is safe to register as Scoped and used per /// every AdminUI page uses — so the service is safe to register as Scoped and used per
/// Blazor circuit. /// Blazor circuit.
/// </remarks> /// </remarks>
public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory) : IUnsTreeService public sealed class UnsTreeService(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
IEffectiveNameGuard? guard = null) : IUnsTreeService
{ {
// The v3 Batch-3 effective-name uniqueness guard (references / VirtualTags / ScriptedAlarms share the
// equipment's UNS NodeId space). WP2 registers the real implementation; production DI always injects it.
// A null (unregistered) guard falls back to a no-op — collisions are then caught only at the deploy
// gate — which also lets unit tests that don't exercise the guard construct the service with just a
// db factory. Tests that DO exercise the guard pass a fake.
private readonly IEffectiveNameGuard _guard = guard ?? NoOpEffectiveNameGuard.Instance;
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<UnsNode>> LoadStructureAsync(CancellationToken ct = default) public async Task<IReadOnlyList<UnsNode>> LoadStructureAsync(CancellationToken ct = default)
{ {
@@ -97,6 +106,371 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
.ToListAsync(ct); .ToListAsync(ct);
} }
// ---------------------------------------------------------------------------------------------
// v3 UNS reference-only equipment (Batch 3): the Tags tab lists UnsTagReference rows.
// ---------------------------------------------------------------------------------------------
/// <inheritdoc />
public async Task<IReadOnlyList<EquipmentReferenceRow>> LoadReferencesForEquipmentAsync(
string equipmentId, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var refs = await db.UnsTagReferences.AsNoTracking()
.Where(r => r.EquipmentId == equipmentId)
.OrderBy(r => r.SortOrder).ThenBy(r => r.UnsTagReferenceId)
.Select(r => new
{
r.UnsTagReferenceId,
r.TagId,
r.DisplayNameOverride,
r.SortOrder,
r.RowVersion,
})
.ToListAsync(ct);
if (refs.Count == 0) return Array.Empty<EquipmentReferenceRow>();
// Load the backing raw tags (name + inherited datatype/access), then compute each RawPath from the
// equipment cluster's raw topology through the shared RawPathResolver (identity authority).
var tagIds = refs.Select(r => r.TagId).Distinct().ToList();
var tags = (await db.Tags.AsNoTracking()
.Where(t => tagIds.Contains(t.TagId))
.Select(t => new { t.TagId, t.DeviceId, t.TagGroupId, t.Name, t.DataType, t.AccessLevel })
.ToListAsync(ct))
.ToDictionary(t => t.TagId, StringComparer.Ordinal);
var cluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
var resolver = await BuildClusterRawPathResolverAsync(db, cluster, ct);
var rows = new List<EquipmentReferenceRow>(refs.Count);
foreach (var r in refs)
{
if (!tags.TryGetValue(r.TagId, out var tag))
{
// Backing tag missing (should not happen — raw-tag delete is reference-blocked); surface a
// clearly-degraded row rather than dropping it so the operator can remove the dangling ref.
rows.Add(new EquipmentReferenceRow(
r.UnsTagReferenceId, r.DisplayNameOverride ?? "(missing tag)", "(missing tag)",
"", TagAccessLevel.Read, r.DisplayNameOverride, r.SortOrder, r.RowVersion));
continue;
}
var rawPath = resolver?.TryBuildTagPath(tag.DeviceId, tag.TagGroupId, tag.Name) ?? tag.Name;
var effectiveName = string.IsNullOrEmpty(r.DisplayNameOverride) ? tag.Name : r.DisplayNameOverride;
rows.Add(new EquipmentReferenceRow(
r.UnsTagReferenceId, effectiveName, rawPath, tag.DataType, tag.AccessLevel,
r.DisplayNameOverride, r.SortOrder, r.RowVersion));
}
return rows;
}
/// <inheritdoc />
public async Task<UnsMutationResult> AddReferencesAsync(
string equipmentId, IReadOnlyList<string> tagIds, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(tagIds);
var distinctTagIds = tagIds.Where(t => !string.IsNullOrEmpty(t)).Distinct(StringComparer.Ordinal).ToList();
if (distinctTagIds.Count == 0) return new UnsMutationResult(false, "Pick at least one raw tag to reference.");
await using var db = await dbFactory.CreateDbContextAsync(ct);
if (!await db.Equipment.AnyAsync(e => e.EquipmentId == equipmentId, ct))
return new UnsMutationResult(false, $"Equipment '{equipmentId}' not found.");
var equipmentCluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
if (equipmentCluster is null)
return new UnsMutationResult(false, $"Equipment '{equipmentId}' does not resolve to a cluster.");
// Load the backing tags + the cluster each lives in (Tag → Device → DriverInstance.ClusterId).
var tags = await db.Tags.AsNoTracking()
.Where(t => distinctTagIds.Contains(t.TagId))
.Join(db.Devices.AsNoTracking(), t => t.DeviceId, dev => dev.DeviceId, (t, dev) => new { t, dev.DriverInstanceId })
.Join(db.DriverInstances.AsNoTracking(), x => x.DriverInstanceId, d => d.DriverInstanceId,
(x, d) => new { x.t.TagId, x.t.Name, DriverCluster = d.ClusterId })
.ToListAsync(ct);
var tagById = tags.ToDictionary(t => t.TagId, StringComparer.Ordinal);
// Existing references on the equipment (to reject re-referencing the same tag).
var alreadyReferenced = (await db.UnsTagReferences.AsNoTracking()
.Where(r => r.EquipmentId == equipmentId)
.Select(r => r.TagId)
.ToListAsync(ct))
.ToHashSet(StringComparer.Ordinal);
var maxSort = await db.UnsTagReferences.AsNoTracking()
.Where(r => r.EquipmentId == equipmentId)
.Select(r => (int?)r.SortOrder)
.MaxAsync(ct) ?? -1;
// Track effective names proposed within THIS batch so two raw tags sharing a Name are caught (the
// guard sees only persisted rows; a same-batch collision would otherwise slip through).
var batchNames = new HashSet<string>(StringComparer.Ordinal);
var pending = new List<UnsTagReference>();
foreach (var tagId in distinctTagIds)
{
if (!tagById.TryGetValue(tagId, out var tag))
return new UnsMutationResult(false, $"Raw tag '{tagId}' not found.");
if (!string.Equals(tag.DriverCluster, equipmentCluster, StringComparison.Ordinal))
return new UnsMutationResult(false,
$"Raw tag '{tag.Name}' is in cluster '{tag.DriverCluster}' but the equipment is in cluster '{equipmentCluster}' — cross-cluster references are not allowed.");
if (alreadyReferenced.Contains(tagId))
return new UnsMutationResult(false, $"Raw tag '{tag.Name}' is already referenced by this equipment.");
// Effective name at add = the raw tag's Name (no override yet).
if (!batchNames.Add(tag.Name))
return new UnsMutationResult(false,
$"Two selected raw tags share the effective name '{tag.Name}'; set a display-name override so they stay unique within the equipment.");
var collision = await _guard.CheckAsync(equipmentId, tag.Name, EffectiveNameSourceKind.Reference, null, ct);
if (collision is not null) return new UnsMutationResult(false, collision);
pending.Add(new UnsTagReference
{
UnsTagReferenceId = $"UTR-{Guid.NewGuid():N}"[..16],
EquipmentId = equipmentId,
TagId = tagId,
DisplayNameOverride = null,
SortOrder = ++maxSort,
});
}
db.UnsTagReferences.AddRange(pending);
try
{
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
catch (DbUpdateException)
{
// The UX_UnsTagReference_Equip_Tag unique index tripped: a concurrent add slipped a
// same-(equipment,tag) row past the pre-check. (Effective-name collisions have no DB
// uniqueness — those are caught by the authoring guard and the deploy gate, not here.)
return new UnsMutationResult(false, "Add failed — a reference at one of these tags was created concurrently. Reload and retry.");
}
}
/// <inheritdoc />
public async Task<UnsMutationResult> RemoveReferenceAsync(
string unsTagReferenceId, byte[] rowVersion, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var entity = await db.UnsTagReferences.FirstOrDefaultAsync(r => r.UnsTagReferenceId == unsTagReferenceId, ct);
if (entity is null) return new UnsMutationResult(true, null);
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
db.UnsTagReferences.Remove(entity);
try
{
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
catch (DbUpdateConcurrencyException)
{
return new UnsMutationResult(false, "Another user changed this reference while you were viewing it.");
}
catch (Exception ex)
{
return new UnsMutationResult(false, $"Remove failed: {ex.Message}.");
}
}
/// <inheritdoc />
public async Task<UnsMutationResult> SetReferenceOverrideAsync(
string unsTagReferenceId, string? displayNameOverride, byte[] rowVersion, CancellationToken ct = default)
{
var normalized = string.IsNullOrWhiteSpace(displayNameOverride) ? null : displayNameOverride.Trim();
await using var db = await dbFactory.CreateDbContextAsync(ct);
var entity = await db.UnsTagReferences.FirstOrDefaultAsync(r => r.UnsTagReferenceId == unsTagReferenceId, ct);
if (entity is null) return new UnsMutationResult(false, "Row no longer exists.");
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
// Effective name = the override when set, else the backing raw tag's Name.
var rawName = await db.Tags.AsNoTracking()
.Where(t => t.TagId == entity.TagId)
.Select(t => t.Name)
.FirstOrDefaultAsync(ct);
var effectiveName = normalized ?? rawName ?? string.Empty;
var collision = await _guard.CheckAsync(
entity.EquipmentId, effectiveName, EffectiveNameSourceKind.Reference, entity.UnsTagReferenceId, ct);
if (collision is not null) return new UnsMutationResult(false, collision);
entity.DisplayNameOverride = normalized;
try
{
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
catch (DbUpdateConcurrencyException)
{
return new UnsMutationResult(false, "Another user changed this reference while you were editing.");
}
}
/// <inheritdoc />
public async Task<RawNode?> LoadReferencePickerRootAsync(string equipmentId, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var clusterId = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
if (clusterId is null) return null;
var cluster = await db.ServerClusters.AsNoTracking()
.FirstOrDefaultAsync(c => c.ClusterId == clusterId, ct);
if (cluster is null) return null;
// Root child count = cluster-root folders + cluster-root drivers (the RawTree lazily loads the rest
// via IRawTreeService.LoadChildrenAsync, keyed on this node's Cluster kind + EntityId).
var rootFolders = await db.RawFolders.AsNoTracking()
.CountAsync(f => f.ClusterId == clusterId && f.ParentRawFolderId == null, ct);
var rootDrivers = await db.DriverInstances.AsNoTracking()
.CountAsync(d => d.ClusterId == clusterId && d.RawFolderId == null, ct);
return new RawNode
{
Kind = RawNodeKind.Cluster,
Key = $"clu:{clusterId}",
DisplayName = cluster.Name,
ClusterId = clusterId,
EntityId = clusterId,
HasLazyChildren = true,
ChildCount = rootFolders + rootDrivers,
};
}
/// <inheritdoc />
public async Task<IReadOnlyList<string>> LoadDescendantTagIdsAsync(RawNode node, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(node);
if (node.EntityId is null) return Array.Empty<string>();
await using var db = await dbFactory.CreateDbContextAsync(ct);
switch (node.Kind)
{
case RawNodeKind.Device:
// Every tag under the device (across all its tag groups).
return await db.Tags.AsNoTracking()
.Where(t => t.DeviceId == node.EntityId)
.Select(t => t.TagId)
.ToListAsync(ct);
case RawNodeKind.TagGroup:
{
// The group + all descendant groups, then every tag in that group set.
var group = await db.TagGroups.AsNoTracking()
.FirstOrDefaultAsync(g => g.TagGroupId == node.EntityId, ct);
if (group is null) return Array.Empty<string>();
var deviceGroups = await db.TagGroups.AsNoTracking()
.Where(g => g.DeviceId == group.DeviceId)
.Select(g => new { g.TagGroupId, g.ParentTagGroupId })
.ToListAsync(ct);
var childrenByParent = deviceGroups
.Where(g => g.ParentTagGroupId is not null)
.GroupBy(g => g.ParentTagGroupId!, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.Select(x => x.TagGroupId).ToList(), StringComparer.Ordinal);
var groupIds = DescendantGroupsInclusive(node.EntityId, childrenByParent);
return await db.Tags.AsNoTracking()
.Where(t => t.TagGroupId != null && groupIds.Contains(t.TagGroupId))
.Select(t => t.TagId)
.ToListAsync(ct);
}
default:
// The picker only offers "select all" on Device / TagGroup containers.
return Array.Empty<string>();
}
}
/// <summary>Returns <paramref name="rootId"/> plus all transitive descendant group ids (iterative walk).</summary>
private static HashSet<string> DescendantGroupsInclusive(
string rootId, IReadOnlyDictionary<string, List<string>> childrenByParent)
{
var result = new HashSet<string>(StringComparer.Ordinal);
var stack = new Stack<string>();
stack.Push(rootId);
while (stack.Count > 0)
{
var id = stack.Pop();
if (!result.Add(id)) continue;
if (childrenByParent.TryGetValue(id, out var kids))
{
foreach (var kid in kids) stack.Push(kid);
}
}
return result;
}
/// <summary>
/// Builds a <see cref="RawPathResolver"/> over a cluster's raw topology (folders / drivers / devices /
/// tag-groups), so a referenced tag's RawPath can be computed the same way the deploy validator does.
/// Returns <see langword="null"/> when the cluster is unknown/null (callers then fall back to bare name).
/// </summary>
private static async Task<RawPathResolver?> BuildClusterRawPathResolverAsync(
OtOpcUaConfigDbContext db, string? clusterId, CancellationToken ct)
{
if (string.IsNullOrEmpty(clusterId)) return null;
var folders = (await db.RawFolders.AsNoTracking()
.Where(f => f.ClusterId == clusterId)
.Select(f => new { f.RawFolderId, f.ParentRawFolderId, f.Name })
.ToListAsync(ct))
.ToDictionary(f => f.RawFolderId, f => ((string?)f.ParentRawFolderId, f.Name), StringComparer.Ordinal);
var drivers = (await db.DriverInstances.AsNoTracking()
.Where(d => d.ClusterId == clusterId)
.Select(d => new { d.DriverInstanceId, d.RawFolderId, d.Name })
.ToListAsync(ct))
.ToDictionary(d => d.DriverInstanceId, d => ((string?)d.RawFolderId, d.Name), StringComparer.Ordinal);
var driverIds = drivers.Keys.ToList();
var devices = (await db.Devices.AsNoTracking()
.Where(dev => driverIds.Contains(dev.DriverInstanceId))
.Select(dev => new { dev.DeviceId, dev.DriverInstanceId, dev.Name })
.ToListAsync(ct))
.ToDictionary(dev => dev.DeviceId, dev => (dev.DriverInstanceId, dev.Name), StringComparer.Ordinal);
var deviceIds = devices.Keys.ToList();
var groups = (await db.TagGroups.AsNoTracking()
.Where(g => deviceIds.Contains(g.DeviceId))
.Select(g => new { g.TagGroupId, g.ParentTagGroupId, g.Name })
.ToListAsync(ct))
.ToDictionary(g => g.TagGroupId, g => ((string?)g.ParentTagGroupId, g.Name), StringComparer.Ordinal);
return new RawPathResolver(folders, drivers, devices, groups);
}
/// <summary>
/// A no-op <see cref="IEffectiveNameGuard"/> used only when no guard was injected (unit tests that do
/// not exercise the guard; a mis-wired DI). It always reports "free" — the deploy gate remains the
/// backstop. Production always injects the real WP2 guard.
/// </summary>
private sealed class NoOpEffectiveNameGuard : IEffectiveNameGuard
{
public static readonly NoOpEffectiveNameGuard Instance = new();
public Task<string?> CheckAsync(
string equipmentId, string proposedEffectiveName, EffectiveNameSourceKind kind,
string? excludeSourceId, CancellationToken ct = default) => Task.FromResult<string?>(null);
}
/// <inheritdoc /> /// <inheritdoc />
public async Task<AreaEditDto?> LoadAreaAsync(string unsAreaId, CancellationToken ct = default) public async Task<AreaEditDto?> LoadAreaAsync(string unsAreaId, CancellationToken ct = default)
{ {
@@ -446,11 +820,8 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
return new UnsMutationResult(false, $"MachineCode '{input.MachineCode}' already exists in this fleet."); return new UnsMutationResult(false, $"MachineCode '{input.MachineCode}' already exists in this fleet.");
} }
var guard = await CheckDriverClusterGuardAsync(db, input, ct); // v3: equipment no longer binds a driver — the reference-only Tags tab points at raw tags — so
if (guard is not null) // the old decision-#122 driver-cluster guard is gone.
{
return guard.Value;
}
var uuid = Guid.NewGuid(); var uuid = Guid.NewGuid();
var equipmentId = $"EQ-{uuid.ToString("N")[..12]}"; var equipmentId = $"EQ-{uuid.ToString("N")[..12]}";
@@ -514,14 +885,7 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
continue; continue;
} }
// Reuse the driver/line cluster guard: it reports an unknown DriverInstanceId and a // v3: equipment no longer binds a driver, so there is no per-row driver-cluster guard.
// driver/line cluster mismatch, and is a no-op for driver-less rows.
var guard = await CheckDriverClusterGuardAsync(db, row, ct);
if (guard is not null)
{
errors.Add($"Row '{row.MachineCode}': {guard.Value.Error}");
continue;
}
var uuid = Guid.NewGuid(); var uuid = Guid.NewGuid();
var equipmentId = $"EQ-{uuid.ToString("N")[..12]}"; var equipmentId = $"EQ-{uuid.ToString("N")[..12]}";
@@ -578,11 +942,7 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
return new UnsMutationResult(false, $"MachineCode '{input.MachineCode}' already exists in this fleet."); return new UnsMutationResult(false, $"MachineCode '{input.MachineCode}' already exists in this fleet.");
} }
var guard = await CheckDriverClusterGuardAsync(db, input, ct); // v3: equipment no longer binds a driver — no decision-#122 driver-cluster guard.
if (guard is not null)
{
return guard.Value;
}
// Equipment↔driver binding retired in v3 — no DriverInstanceId column remains. // Equipment↔driver binding retired in v3 — no DriverInstanceId column remains.
entity.UnsLineId = input.UnsLineId; entity.UnsLineId = input.UnsLineId;
@@ -866,29 +1226,82 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
} }
/// <summary> /// <summary>
/// When the bound script uses the reserved <c>{{equip}}</c> token, the owning equipment must have /// v3 (M1): when the bound script uses <c>{{equip}}/&lt;RefName&gt;</c>, every <c>&lt;RefName&gt;</c> must
/// a derivable tag base (≥1 driver tag, all sharing one object prefix). Returns a rejection result /// resolve to one of the owning equipment's <c>UnsTagReference</c> effective names (its
/// when the token is present but no base can be derived; <c>null</c> otherwise (token absent, or a /// <c>DisplayNameOverride</c> else the backing raw tag's <c>Name</c>) — the same set the compose seams
/// base exists). The script source is fetched from the supplied (in-scope) context. /// substitute against and the deploy gate (<c>DraftValidator.ValidateEquipReferenceResolution</c>)
/// enforces. Returns a readable rejection naming the missing ref(s) when any is unresolved; <c>null</c>
/// otherwise (no slash-token, or all resolve). Replaces the v2 <c>DeriveEquipmentBase(empty)→null</c>
/// check, which rejected ALL <c>{{equip}}</c> VirtualTags with a now-impossible "add a driver tag" message.
/// </summary> /// </summary>
private static async Task<UnsMutationResult?> ValidateEquipTokenAsync( private static async Task<UnsMutationResult?> ValidateEquipTokenAsync(
OtOpcUaConfigDbContext db, string equipmentId, string scriptId, CancellationToken ct) OtOpcUaConfigDbContext db, string equipmentId, string scriptId, CancellationToken ct)
{ {
var src = await db.Scripts.Where(s => s.ScriptId == scriptId) var src = await db.Scripts.Where(s => s.ScriptId == scriptId)
.Select(s => s.SourceCode).FirstOrDefaultAsync(ct); .Select(s => s.SourceCode).FirstOrDefaultAsync(ct);
if (!EquipmentScriptPaths.ContainsEquipToken(src)) return null; var used = EquipmentScriptPaths.ExtractEquipReferenceNames(src);
if (used.Count == 0) return null;
var effectiveNames = await LoadEquipReferenceNamesAsync(db, equipmentId, ct);
return CheckEquipReferencesResolve(used, effectiveNames, equipmentId);
}
// The equipment↔Tag binding was retired in v3 (Tag is Raw-only), so no equipment-bound driver /// <summary>
// tags exist to derive an equipment tag base from. The {{equip}} token can't be resolved here. /// Scripted-alarm variant of <see cref="ValidateEquipTokenAsync"/>: validates <c>{{equip}}/&lt;RefName&gt;</c>
var fullNames = Array.Empty<string>(); /// tokens in BOTH the predicate script AND the message template against the equipment's reference set —
if (EquipmentScriptPaths.DeriveEquipmentBase(fullNames) is null) /// the authoring mirror of the deploy gate, which covers all three (VT scripts, SA predicates, SA templates).
/// Keeps the editor⇔authoring⇔deploy invariant tight on the SA surface.
/// </summary>
private static async Task<UnsMutationResult?> ValidateEquipTokenForAlarmAsync(
OtOpcUaConfigDbContext db, string equipmentId, string? predicateScriptId, string? messageTemplate, CancellationToken ct)
{
var used = new HashSet<string>(StringComparer.Ordinal);
if (!string.IsNullOrEmpty(predicateScriptId))
{ {
return new UnsMutationResult(false, var src = await db.Scripts.Where(s => s.ScriptId == predicateScriptId)
$"Equipment '{equipmentId}' has no single tag base, so the " .Select(s => s.SourceCode).FirstOrDefaultAsync(ct);
+ $"{EquipmentScriptPaths.EquipToken} token can't be resolved. Add at least one driver tag under this " foreach (var u in EquipmentScriptPaths.ExtractEquipReferenceNames(src)) used.Add(u);
+ $"equipment (all sharing one object prefix), or remove {EquipmentScriptPaths.EquipToken} from the script.");
} }
return null; foreach (var u in EquipmentScriptPaths.ExtractEquipReferenceNamesFromText(messageTemplate)) used.Add(u);
if (used.Count == 0) return null;
var effectiveNames = await LoadEquipReferenceNamesAsync(db, equipmentId, ct);
return CheckEquipReferencesResolve(used, effectiveNames, equipmentId);
}
/// <summary>The equipment's reference effective-name set: <c>DisplayNameOverride</c> else the backing raw
/// tag's <c>Name</c> (empty override treated as absent, matching <c>EquipmentReferenceMap.Build</c>), ordinal.</summary>
private static async Task<HashSet<string>> LoadEquipReferenceNamesAsync(
OtOpcUaConfigDbContext db, string equipmentId, CancellationToken ct)
{
var refs = await db.UnsTagReferences.AsNoTracking()
.Where(r => r.EquipmentId == equipmentId)
.Select(r => new { r.TagId, r.DisplayNameOverride })
.ToListAsync(ct);
var tagIds = refs.Select(r => r.TagId).ToList();
var tagNameById = await db.Tags.AsNoTracking()
.Where(t => tagIds.Contains(t.TagId))
.Select(t => new { t.TagId, t.Name })
.ToDictionaryAsync(t => t.TagId, t => t.Name, ct);
var effectiveNames = new HashSet<string>(StringComparer.Ordinal);
foreach (var r in refs)
{
var eff = string.IsNullOrEmpty(r.DisplayNameOverride)
? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null)
: r.DisplayNameOverride;
if (!string.IsNullOrEmpty(eff)) effectiveNames.Add(eff!);
}
return effectiveNames;
}
/// <summary>Names the unresolved <c>{{equip}}/&lt;RefName&gt;</c> tokens (or null when all resolve).</summary>
private static UnsMutationResult? CheckEquipReferencesResolve(
IReadOnlyCollection<string> used, HashSet<string> effectiveNames, string equipmentId)
{
var missing = used.Where(u => !effectiveNames.Contains(u)).ToList();
if (missing.Count == 0) return null;
return new UnsMutationResult(false,
$"Script uses {string.Join(", ", missing.Select(m => $"'{EquipmentScriptPaths.EquipTokenPrefix}{m}'"))} "
+ $"but equipment '{equipmentId}' has no reference with that effective name. "
+ "Add a matching reference on the Tags tab, or correct the script.");
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -920,6 +1333,11 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
return new UnsMutationResult(false, $"A virtual tag named '{input.Name}' already exists on this equipment."); return new UnsMutationResult(false, $"A virtual tag named '{input.Name}' already exists on this equipment.");
} }
// v3 Batch 3: the effective name must also be unique against this equipment's references + scripted
// alarms (they share the equipment's UNS NodeId space) — the authoring mirror of the deploy gate.
var nameGuard = await _guard.CheckAsync(equipmentId, input.Name, EffectiveNameSourceKind.VirtualTag, null, ct);
if (nameGuard is not null) return new UnsMutationResult(false, nameGuard);
var equipGuard = await ValidateEquipTokenAsync(db, equipmentId, input.ScriptId, ct); var equipGuard = await ValidateEquipTokenAsync(db, equipmentId, input.ScriptId, ct);
if (equipGuard is not null) return equipGuard.Value; if (equipGuard is not null) return equipGuard.Value;
@@ -969,6 +1387,10 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
return new UnsMutationResult(false, $"A virtual tag named '{input.Name}' already exists on this equipment."); return new UnsMutationResult(false, $"A virtual tag named '{input.Name}' already exists on this equipment.");
} }
// v3 Batch 3: effective-name uniqueness across references + scripted alarms (excluding this VT).
var nameGuard = await _guard.CheckAsync(entity.EquipmentId, input.Name, EffectiveNameSourceKind.VirtualTag, virtualTagId, ct);
if (nameGuard is not null) return new UnsMutationResult(false, nameGuard);
var equipGuard = await ValidateEquipTokenAsync(db, entity.EquipmentId, input.ScriptId, ct); var equipGuard = await ValidateEquipTokenAsync(db, entity.EquipmentId, input.ScriptId, ct);
if (equipGuard is not null) return equipGuard.Value; if (equipGuard is not null) return equipGuard.Value;
@@ -1193,58 +1615,6 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
return null; return null;
} }
/// <summary>
/// An equipment may only bind to a driver in the same cluster as its line.
/// Policy:
/// <list type="bullet">
/// <item>Driver-less (<c>DriverInstanceId</c> empty) → always allowed (returns <c>null</c>).</item>
/// <item>Driver bound but the line/area does not resolve to a cluster → rejected (unresolvable
/// line cannot guarantee cluster alignment, so the bind is unsafe).</item>
/// <item>Driver bound, line resolves, clusters differ → rejected.</item>
/// <item>Driver bound, line resolves, clusters match → allowed (returns <c>null</c>).</item>
/// </list>
/// </summary>
private static async Task<UnsMutationResult?> CheckDriverClusterGuardAsync(
OtOpcUaConfigDbContext db,
EquipmentInput input,
CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(input.DriverInstanceId))
{
return null;
}
var line = await db.UnsLines.FirstOrDefaultAsync(l => l.UnsLineId == input.UnsLineId, ct);
var area = line is null ? null : await db.UnsAreas.FirstOrDefaultAsync(a => a.UnsAreaId == line.UnsAreaId, ct);
var lineCluster = area?.ClusterId;
if (lineCluster is null)
{
return new UnsMutationResult(
false,
$"Cannot bind driver '{input.DriverInstanceId}': UNS line '{input.UnsLineId}' does not resolve to a cluster (decision #122).");
}
var driverCluster = await db.DriverInstances
.Where(d => d.DriverInstanceId == input.DriverInstanceId)
.Select(d => d.ClusterId)
.FirstOrDefaultAsync(ct);
if (driverCluster is null)
{
return new UnsMutationResult(false, $"Driver '{input.DriverInstanceId}' not found.");
}
if (driverCluster != lineCluster)
{
return new UnsMutationResult(
false,
$"Driver '{input.DriverInstanceId}' is in cluster '{driverCluster}' but the line is in cluster '{lineCluster}' (decision #122).");
}
return null;
}
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<EquipmentAlarmRow>> LoadAlarmsForEquipmentAsync(string equipmentId, CancellationToken ct = default) public async Task<IReadOnlyList<EquipmentAlarmRow>> LoadAlarmsForEquipmentAsync(string equipmentId, CancellationToken ct = default)
{ {
@@ -1277,6 +1647,14 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
if (await db.ScriptedAlarms.AnyAsync(a => a.EquipmentId == equipmentId && a.Name == input.Name, ct)) if (await db.ScriptedAlarms.AnyAsync(a => a.EquipmentId == equipmentId && a.Name == input.Name, ct))
return new UnsMutationResult(false, $"A scripted alarm named '{input.Name}' already exists on this equipment."); return new UnsMutationResult(false, $"A scripted alarm named '{input.Name}' already exists on this equipment.");
// v3 Batch 3: effective-name uniqueness across references + VirtualTags on this equipment.
var nameGuard = await _guard.CheckAsync(equipmentId, input.Name, EffectiveNameSourceKind.ScriptedAlarm, null, ct);
if (nameGuard is not null) return new UnsMutationResult(false, nameGuard);
// v3 Batch 3 (WP4): {{equip}}/<RefName> in the predicate script or message template must resolve.
var equipGuard = await ValidateEquipTokenForAlarmAsync(db, equipmentId, input.PredicateScriptId, input.MessageTemplate, ct);
if (equipGuard is not null) return equipGuard.Value;
db.ScriptedAlarms.Add(new ScriptedAlarm db.ScriptedAlarms.Add(new ScriptedAlarm
{ {
ScriptedAlarmId = input.ScriptedAlarmId, ScriptedAlarmId = input.ScriptedAlarmId,
@@ -1307,6 +1685,14 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
if (await db.ScriptedAlarms.AnyAsync(a => a.EquipmentId == entity.EquipmentId && a.Name == input.Name && a.ScriptedAlarmId != scriptedAlarmId, ct)) if (await db.ScriptedAlarms.AnyAsync(a => a.EquipmentId == entity.EquipmentId && a.Name == input.Name && a.ScriptedAlarmId != scriptedAlarmId, ct))
return new UnsMutationResult(false, $"A scripted alarm named '{input.Name}' already exists on this equipment."); return new UnsMutationResult(false, $"A scripted alarm named '{input.Name}' already exists on this equipment.");
// v3 Batch 3: effective-name uniqueness across references + VirtualTags (excluding this alarm).
var nameGuard = await _guard.CheckAsync(entity.EquipmentId, input.Name, EffectiveNameSourceKind.ScriptedAlarm, scriptedAlarmId, ct);
if (nameGuard is not null) return new UnsMutationResult(false, nameGuard);
// v3 Batch 3 (WP4): {{equip}}/<RefName> in the predicate script or message template must resolve.
var equipGuard = await ValidateEquipTokenForAlarmAsync(db, entity.EquipmentId, input.PredicateScriptId, input.MessageTemplate, ct);
if (equipGuard is not null) return equipGuard.Value;
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
entity.Name = input.Name; entity.Name = input.Name;
entity.AlarmType = input.AlarmType; entity.AlarmType = input.AlarmType;
@@ -10,6 +10,7 @@
<FrameworkReference Include="Microsoft.AspNetCore.App"/> <FrameworkReference Include="Microsoft.AspNetCore.App"/>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client"/> <PackageReference Include="Microsoft.AspNetCore.SignalR.Client"/>
<PackageReference Include="ZB.MOM.WW.Theme"/> <PackageReference Include="ZB.MOM.WW.Theme"/>
<PackageReference Include="ZB.MOM.WW.Secrets.Ui"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -23,6 +23,11 @@
const KIND_MAP = { Method: 0, Field: 3, Property: 9, Event: 10, Class: 5, Module: 8, Variable: 4, Text: 18 }; const KIND_MAP = { Method: 0, Field: 3, Property: 9, Event: 10, Class: 5, Module: 8, Variable: 4, Text: 18 };
// The owning-equipment id is stamped onto each editor's model at createEditor time so the globally
// registered csharp providers (which only receive the model) can thread it into the {{equip}}/<RefName>
// completion + diagnostics. Null on the shared ScriptEdit page (equip-ref features inert there).
function equipmentIdOf(model) { return (model && model.__otEquipmentId) || null; }
function registerCSharpProviders() { function registerCSharpProviders() {
monaco.languages.registerCompletionItemProvider("csharp", { monaco.languages.registerCompletionItemProvider("csharp", {
triggerCharacters: [".", "(", "\""], triggerCharacters: [".", "(", "\""],
@@ -31,7 +36,7 @@
const resp = await fetch("/api/script-analysis/completions", { const resp = await fetch("/api/script-analysis/completions", {
method: "POST", credentials: "same-origin", method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column }) body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column, equipmentId: equipmentIdOf(model) })
}); });
if (!resp.ok) return { suggestions: [] }; if (!resp.ok) return { suggestions: [] };
const data = await resp.json(); const data = await resp.json();
@@ -72,7 +77,7 @@
const resp = await fetch("/api/script-analysis/hover", { const resp = await fetch("/api/script-analysis/hover", {
method: "POST", credentials: "same-origin", method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column }) body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column, equipmentId: equipmentIdOf(model) })
}); });
if (!resp.ok) return null; if (!resp.ok) return null;
const data = await resp.json(); const data = await resp.json();
@@ -149,7 +154,7 @@
const resp = await fetch("/api/script-analysis/diagnostics", { const resp = await fetch("/api/script-analysis/diagnostics", {
method: "POST", credentials: "same-origin", method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: model.getValue() }) body: JSON.stringify({ code: model.getValue(), equipmentId: equipmentIdOf(model) })
}); });
if (!resp.ok) return []; if (!resp.ok) return [];
const data = await resp.json(); const data = await resp.json();
@@ -192,6 +197,9 @@
// inside ctx.GetTag("…") / ctx.SetVirtualTag("…") without requiring an explicit Ctrl+Space. // inside ctx.GetTag("…") / ctx.SetVirtualTag("…") without requiring an explicit Ctrl+Space.
quickSuggestions: { other: true, comments: false, strings: true } quickSuggestions: { other: true, comments: false, strings: true }
}); });
// Stamp the owning-equipment id on the model so the global csharp providers can thread it into the
// {{equip}}/<RefName> completion + diagnostics (null on the shared ScriptEdit page).
try { const m0 = editor.getModel(); if (m0) m0.__otEquipmentId = options.equipmentId || null; } catch (x) {}
let diagTimer = null; let diagTimer = null;
const scheduleDiagnostics = function () { const scheduleDiagnostics = function () {
if (diagTimer) clearTimeout(diagTimer); if (diagTimer) clearTimeout(diagTimer);
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Core.Resilience; using ZB.MOM.WW.OtOpcUa.Core.Resilience;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Host.Drivers; 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 // 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). // 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>(); 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; return registry;
}); });
services.AddSingleton<IDriverFactory>(sp => services.AddSingleton<IDriverFactory>(sp =>
@@ -131,15 +135,16 @@ public static class DriverFactoryBootstrap
private static void Register( private static void Register(
DriverFactoryRegistry registry, DriverFactoryRegistry registry,
ILoggerFactory? loggerFactory, ILoggerFactory? loggerFactory,
ISecretResolver secretResolver,
ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger? scriptRoot = null) ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger? scriptRoot = null)
{ {
Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry); Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry);
Driver.AbLegacy.AbLegacyDriverFactoryExtensions.Register(registry, loggerFactory); Driver.AbLegacy.AbLegacyDriverFactoryExtensions.Register(registry, loggerFactory);
Driver.Calculation.CalculationDriverFactoryExtensions.Register(registry, loggerFactory, scriptRoot); Driver.Calculation.CalculationDriverFactoryExtensions.Register(registry, loggerFactory, scriptRoot);
Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry); 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.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory);
Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory); Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory, secretResolver);
Driver.S7.S7DriverFactoryExtensions.Register(registry); Driver.S7.S7DriverFactoryExtensions.Register(registry);
Driver.TwinCAT.TwinCATDriverFactoryExtensions.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.Configuration;
using ZB.MOM.WW.Telemetry.Serilog; using ZB.MOM.WW.Telemetry.Serilog;
using ZB.MOM.WW.OtOpcUa.AdminUI.Api; 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. // Roles drive the entire conditional wiring below — see ZB.MOM.WW.OtOpcUa.Cluster.RoleParser.
var roles = RoleParser.Parse(Environment.GetEnvironmentVariable("OTOPCUA_ROLES")); var roles = RoleParser.Parse(Environment.GetEnvironmentVariable("OTOPCUA_ROLES"));
@@ -72,6 +77,27 @@ if (roleSuffix is not null)
builder.Configuration.AddCommandLine(args); 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 // 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 // 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 // service starts with CWD=C:\Windows\System32, which otherwise scattered the Serilog rolling-file sink
@@ -323,6 +349,9 @@ if (hasAdmin)
builder.Services.AddOtOpcUaAdminClients(); 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.AddOtOpcUaHealth();
builder.Services.AddOtOpcUaObservability(builder.Configuration); builder.Services.AddOtOpcUaObservability(builder.Configuration);
@@ -33,6 +33,8 @@
<PackageReference Include="ZB.MOM.WW.Telemetry" /> <PackageReference Include="ZB.MOM.WW.Telemetry" />
<PackageReference Include="ZB.MOM.WW.Telemetry.Serilog" /> <PackageReference Include="ZB.MOM.WW.Telemetry.Serilog" />
<PackageReference Include="ZB.MOM.WW.Configuration" /> <PackageReference Include="ZB.MOM.WW.Configuration" />
<PackageReference Include="ZB.MOM.WW.Secrets" />
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -11,12 +11,21 @@
"DisableLogin": false "DisableLogin": false
} }
}, },
"Secrets": {
"SqlitePath": "otopcua-secrets.db",
"MasterKey": {
"Source": "Environment",
"EnvVarName": "ZB_SECRETS_MASTER_KEY"
},
"RunMigrationsOnStartup": true,
"ResolveCacheTtl": "00:00:30"
},
"ServerHistorian": { "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.", "_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, "Enabled": false,
"Endpoint": "", "Endpoint": "",
"ApiKey": "", "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, "UseTls": true,
"AllowUntrustedServerCertificate": false, "AllowUntrustedServerCertificate": false,
"CaCertificatePath": null, "CaCertificatePath": null,
@@ -1,5 +1,6 @@
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; 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.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
@@ -96,29 +97,33 @@ public sealed class AddressSpaceApplier
var failedNodes = 0; var failedNodes = 0;
foreach (var eq in plan.RemovedEquipment) foreach (var eq in plan.RemovedEquipment)
{ {
if (!SafeWriteAlarmCondition(eq.EquipmentId, RemovedConditionState, ts)) failedNodes++; if (!SafeWriteAlarmCondition(eq.EquipmentId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++;
removedCount++; removedCount++;
} }
foreach (var alarm in plan.RemovedAlarms) foreach (var alarm in plan.RemovedAlarms)
{ {
if (!SafeWriteAlarmCondition(alarm.ScriptedAlarmId, RemovedConditionState, ts)) failedNodes++; if (!SafeWriteAlarmCondition(alarm.ScriptedAlarmId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++;
removedCount++; removedCount++;
} }
// Removed equipment tags / VirtualTags are plain variable nodes (no Part 9 condition to write // 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 // 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. // is accurate on a removed-tag-only deploy, which now reaches the rebuild path below. v3 Batch 4:
removedCount += plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count; // 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 = var changedCount =
plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count + plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count +
plan.ChangedEquipmentTags.Count + plan.ChangedEquipmentTags.Count +
plan.ChangedEquipmentVirtualTags.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. // A UNS Area/Line rename is an in-place change to an existing folder node.
plan.RenamedFolders.Count; plan.RenamedFolders.Count;
var addedCount = var addedCount =
plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count + plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count +
plan.AddedEquipmentTags.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 // 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 // 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) foreach (var rename in renamedFolders)
{ {
bool ok; bool ok;
try { ok = surgical.UpdateFolderDisplayName(rename.FolderNodeId, rename.NewDisplayName); } try { ok = surgical.UpdateFolderDisplayName(rename.FolderNodeId, rename.NewDisplayName, AddressSpaceRealm.Uns); }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "AddressSpaceApplier: surgical UpdateFolderDisplayName threw for {Node}", rename.FolderNodeId); _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 // 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 // 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). // 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 writable = d.Current.Writable && !d.Current.IsArray;
var historian = d.Current.IsHistorized var historian = d.Current.IsHistorized
? (string.IsNullOrWhiteSpace(d.Current.HistorianTagname) ? d.Current.FullName : d.Current.HistorianTagname) ? (string.IsNullOrWhiteSpace(d.Current.HistorianTagname) ? d.Current.FullName : d.Current.HistorianTagname)
: null; : null;
bool ok; 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) catch (Exception ex)
{ {
_logger.LogError(ex, "AddressSpaceApplier: surgical UpdateTagAttributes threw for {Node}", nodeId); _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. // Removed equipment tags — value variables OR alarm-bearing conditions — for SURVIVING equipment.
foreach (var tag in plan.RemovedEquipmentTags.Where(t => NotSubsumed(t.EquipmentId))) 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) if (tag.Alarm is not null)
{ {
// Alarm-bearing tag → a Part 9 condition node. Terminal RemovedConditionState (today-gap // Alarm-bearing tag → a Part 9 condition node. Terminal RemovedConditionState (today-gap
// closed), then remove the condition in place. // closed), then remove the condition in place.
if (!SafeWriteAlarmCondition(nodeId, RemovedConditionState, ts)) failedNodes++; if (!SafeWriteAlarmCondition(nodeId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++;
if (!SafeRemoveAlarmCondition(surgical, nodeId)) return false; if (!SafeRemoveAlarmCondition(surgical, nodeId, AddressSpaceRealm.Uns)) return false;
} }
else else
{ {
// Plain value variable → terminal Bad so an in-flight MonitoredItem sees the removal. // Plain value variable → terminal Bad so an in-flight MonitoredItem sees the removal.
SafeWriteValue(nodeId, null, OpcUaQuality.Bad, ts); SafeWriteValue(nodeId, null, OpcUaQuality.Bad, ts, AddressSpaceRealm.Uns);
if (!SafeRemoveVariable(surgical, nodeId)) return false; if (!SafeRemoveVariable(surgical, nodeId, AddressSpaceRealm.Uns)) return false;
} }
} }
// Removed VirtualTags (always plain value variables) for surviving equipment. // Removed VirtualTags (always plain value variables) for surviving equipment.
foreach (var v in plan.RemovedEquipmentVirtualTags.Where(t => NotSubsumed(t.EquipmentId))) foreach (var v in plan.RemovedEquipmentVirtualTags.Where(t => NotSubsumed(t.EquipmentId)))
{ {
var nodeId = EquipmentNodeIds.Variable(v.EquipmentId, v.FolderPath, v.Name); var nodeId = UnsEquipmentVar(v.EquipmentId, v.FolderPath, v.Name);
SafeWriteValue(nodeId, null, OpcUaQuality.Bad, ts); SafeWriteValue(nodeId, null, OpcUaQuality.Bad, ts, AddressSpaceRealm.Uns);
if (!SafeRemoveVariable(surgical, nodeId)) return false; if (!SafeRemoveVariable(surgical, nodeId, AddressSpaceRealm.Uns)) return false;
} }
// Removed scripted alarms for surviving equipment — the terminal RemovedConditionState was already // 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. // 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))) 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 // 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. // top-of-Apply block already wrote the equipment id's terminal condition state.
foreach (var eq in plan.RemovedEquipment) 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; return true;
} }
/// <summary>Remove a value-variable node in place, treating a throw as a false (⇒ rebuild fallback).</summary> /// <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> /// <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; } 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> /// <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> /// <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; } 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> /// <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> /// <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; } 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 /// <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> /// 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> /// <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; } 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> /// <param name="folderPath">The tag/vtag FolderPath, or null/empty for "directly under the equipment".</param>
/// <returns>The materialise-parent node id.</returns> /// <returns>The materialise-parent node id.</returns>
private static string MaterialiseParent(string equipmentId, string? folderPath) => 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> /// <summary>
/// Announce a Part 3 <c>GeneralModelChangeEvent(NodeAdded)</c> per distinct affected parent from /// 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)) 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); } catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: RaiseNodesAddedModelChange threw for {Node}", id); }
} }
} }
@@ -407,11 +458,11 @@ public sealed class AddressSpaceApplier
try try
{ {
List<HistorianTagProvisionRequest>? requests = null; 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; if (!tag.IsHistorized || tag.Alarm is not null) continue;
// Parse the driver-agnostic data type from the tag's DataType string. An unparseable // Parse the driver-agnostic data type from the tag's DataType string. An unparseable
@@ -424,9 +475,9 @@ public sealed class AddressSpaceApplier
continue; continue;
} }
// Resolve the historian name EXACTLY as MaterialiseEquipmentTags does: a null/blank // Resolve the historian name EXACTLY as MaterialiseRawSubtree does: a null/blank override
// override falls back to the driver-side FullName. // falls back to the RawPath (== NodeId).
var historianName = string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname; var historianName = string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.NodeId : tag.HistorianTagname;
(requests ??= new List<HistorianTagProvisionRequest>()).Add( (requests ??= new List<HistorianTagProvisionRequest>()).Add(
new HistorianTagProvisionRequest(historianName, dataType, EngineeringUnit: null, Description: tag.Name)); new HistorianTagProvisionRequest(historianName, dataType, EngineeringUnit: null, Description: tag.Name));
} }
@@ -493,22 +544,26 @@ public sealed class AddressSpaceApplier
List<HistorizedTagRef>? added = null; List<HistorizedTagRef>? added = null;
List<HistorizedTagRef>? removed = null; List<HistorizedTagRef>? removed = null;
// Added historized value variables → new interest. // v3 Batch 4: historized value tags are RAW tags (keyed by RawPath). The mux HistorizedTagRef
foreach (var tag in plan.AddedEquipmentTags) // 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); if (HistorizedRef(tag) is { } r) (added ??= new List<HistorizedTagRef>()).Add(r);
} }
// Removed historized value variables → drop interest. // Removed historized raw value tags → drop interest.
foreach (var tag in plan.RemovedEquipmentTags) foreach (var tag in plan.RemovedRawTags)
{ {
if (HistorizedRef(tag) is { } r) (removed ??= new List<HistorizedTagRef>()).Add(r); 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 // Changed raw tags: the historized ref may have flipped on/off or the historian override changed
// change). Compare previous-vs-current resolved ref PAIRS (record equality compares both the // (a rename is remove+add, handled above). Compare previous-vs-current resolved ref PAIRS — an
// mux ref and the historian name) — an unchanged pair is a no-op. // unchanged pair is a no-op.
foreach (var d in plan.ChangedEquipmentTags) foreach (var d in plan.ChangedRawTags)
{ {
var prev = HistorizedRef(d.Previous); var prev = HistorizedRef(d.Previous);
var cur = HistorizedRef(d.Current); 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 /// 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). /// value variable (not historized, or a native-alarm condition node).
/// </summary> /// </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> /// <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 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( ? new HistorizedTagRef(
tag.FullName, tag.NodeId,
string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname) string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.NodeId : tag.HistorianTagname)
: null; : null;
/// <summary>Rebuild the sink's address space, swallowing (and Error-logging) any fault. /// <summary>Rebuild the sink's address space, swallowing (and Error-logging) any fault.
@@ -585,17 +642,17 @@ public sealed class AddressSpaceApplier
var failed = 0; var failed = 0;
foreach (var area in composition.UnsAreas) 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) 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) foreach (var equipment in composition.EquipmentNodes)
{ {
// Equipment with no UnsLineId (legacy / dev rows) hang under the root. // Equipment with no UnsLineId (legacy / dev rows) hang under the root.
var parent = string.IsNullOrWhiteSpace(equipment.UnsLineId) ? null : equipment.UnsLineId; 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( _logger.LogInformation(
@@ -604,6 +661,153 @@ public sealed class AddressSpaceApplier
return failed; 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> /// <summary>
/// Materialise Equipment-namespace tags from a composition snapshot. /// Materialise Equipment-namespace tags from a composition snapshot.
/// For each <see cref="EquipmentTagPlan"/>, /// For each <see cref="EquipmentTagPlan"/>,
@@ -635,9 +839,9 @@ public sealed class AddressSpaceApplier
foreach (var tag in composition.EquipmentTags) foreach (var tag in composition.EquipmentTags)
{ {
if (string.IsNullOrWhiteSpace(tag.FolderPath)) continue; 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 (!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 // 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) var parent = string.IsNullOrWhiteSpace(tag.FolderPath)
? tag.EquipmentId ? tag.EquipmentId
: EquipmentNodeIds.SubFolder(tag.EquipmentId, tag.FolderPath); : UnsSubFolder(tag.EquipmentId, tag.FolderPath);
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) if (tag.Alarm is not null)
{ {
// Native alarm tag → a real Part 9 condition node (reuses the scripted-alarm path), // 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. // 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 else
{ {
@@ -670,7 +874,7 @@ public sealed class AddressSpaceApplier
// even if authored ReadWrite, so a client write cannot reach the driver write path which // 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). // does not handle arrays (e.g. S7 BoxValueForWrite would crash).
var writable = tag.Writable && !tag.IsArray; 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; var failed = 0;
// Parent-first: a child folder's parent must exist before it. Ordering by '/' count == depth. // 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 == '/'))) 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) foreach (var v in variables)
{ {
// Mirror MaterialiseEquipmentTags: arrays forced read-only (the driver write path can't handle arrays). // Mirror MaterialiseEquipmentTags: arrays forced read-only (the driver write path can't handle arrays).
var writable = v.Writable && !v.IsArray; var writable = v.Writable && !v.IsArray;
if (!SafeEnsureVariable(v.NodeId, v.ParentNodeId, v.DisplayName, v.DataType, writable, 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( _logger.LogInformation(
"AddressSpaceApplier: discovered nodes materialised under {Equipment} (folders={Folders}, vars={Vars}, failed={Failed})", "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) foreach (var v in composition.EquipmentVirtualTags)
{ {
if (string.IsNullOrWhiteSpace(v.FolderPath)) continue; 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 (!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. // 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) var parent = string.IsNullOrWhiteSpace(v.FolderPath)
? v.EquipmentId ? v.EquipmentId
: EquipmentNodeIds.SubFolder(v.EquipmentId, v.FolderPath); : UnsSubFolder(v.EquipmentId, v.FolderPath);
var nodeId = EquipmentNodeIds.Variable(v.EquipmentId, v.FolderPath, v.Name); var nodeId = UnsEquipmentVar(v.EquipmentId, v.FolderPath, v.Name);
// VirtualTags are computed outputs — read-only nodes (no inbound write). // 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( _logger.LogInformation(
@@ -805,7 +1009,7 @@ public sealed class AddressSpaceApplier
foreach (var alarm in composition.EquipmentScriptedAlarms) foreach (var alarm in composition.EquipmentScriptedAlarms)
{ {
if (!alarm.Enabled) continue; 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++; 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 /// 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> /// 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> /// <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; } 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 /// 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> /// count (archreview 01/S-1).</summary>
/// <returns><c>true</c> when the variable was ensured; <c>false</c> when the sink threw.</returns> /// <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; } 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 /// 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> /// 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> /// <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; } 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 /// 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> /// 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> /// <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; } 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. /// <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 / // 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 // 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 // surgical-eligible, and a changed vtag is node-affecting UNLESS it is node-irrelevant. The v3
// BEFORE the add/remove split so a non-surgical change alongside pure adds still rebuilds. // 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 || if (plan.ChangedEquipment.Count > 0 ||
plan.ChangedAlarms.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.ChangedEquipmentTags.Any(d => !TagDeltaIsSurgicalEligible(d)) ||
plan.ChangedEquipmentVirtualTags.Any(d => !VtagDeltaIsNodeIrrelevant(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. // add/remove split below and leave a driver-only plan classified AttributeOnly.
var hasAdds = var hasAdds =
plan.AddedEquipment.Count + plan.AddedAlarms.Count + 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 = var hasRemoves =
plan.RemovedEquipment.Count + plan.RemovedAlarms.Count + 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. // 3 — additions AND removals.
if (hasAdds && hasRemoves) return AddressSpaceChangeKind.AddRemoveMix; if (hasAdds && hasRemoves) return AddressSpaceChangeKind.AddRemoveMix;
@@ -1,4 +1,5 @@
using System.Diagnostics; using System.Diagnostics;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
@@ -53,8 +54,163 @@ public sealed record AddressSpaceComposition(
/// constructor + call site keeps compiling unchanged. /// constructor + call site keeps compiling unchanged.
/// </summary> /// </summary>
public IReadOnlyList<EquipmentScriptedAlarmPlan> EquipmentScriptedAlarms { get; init; } = Array.Empty<EquipmentScriptedAlarmPlan>(); 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 UnsAreaProjection(string UnsAreaId, string DisplayName);
public sealed record UnsLineProjection(string UnsLineId, string UnsAreaId, string DisplayName); public sealed record UnsLineProjection(string UnsLineId, string UnsAreaId, string DisplayName);
@@ -301,12 +457,17 @@ public static class AddressSpaceComposer
/// <summary> /// <summary>
/// Composes the address space build plan from the v3 configuration entities. /// 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 — /// <para><b>v3 dual namespace (Batch 4):</b> the composer emits BOTH subtrees. The
/// <c>EquipmentTags</c> is deliberately empty (the raw + UNS variable nodes are lit up in Batch 4's /// <see cref="AddressSpaceComposition.RawContainers"/> + <see cref="AddressSpaceComposition.RawTags"/>
/// dual namespace). The composer carries the UNS folder hierarchy (Area/Line/Equipment) + /// light up the device-oriented <c>ns=Raw</c> tree (Folder→Driver→Device→TagGroup→Tag, each tag keyed
/// per-equipment VirtualTags + ScriptedAlarms only. Equipment no longer binds a driver/device, so /// <c>s=&lt;RawPath&gt;</c>); the <see cref="AddressSpaceComposition.UnsReferenceVariables"/> light up
/// <see cref="EquipmentNode"/>'s driver/device hooks are always null. This is the pure reference /// the equipment-oriented <c>ns=UNS</c> tree (one Variable per <see cref="UnsTagReference"/>, keyed
/// mirror of <c>DeploymentArtifact.ParseComposition</c> (byte-parity target for the corpus tests).</para> /// <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> /// </summary>
/// <param name="unsAreas">The UNS areas.</param> /// <param name="unsAreas">The UNS areas.</param>
/// <param name="unsLines">The UNS lines.</param> /// <param name="unsLines">The UNS lines.</param>
@@ -315,6 +476,11 @@ public static class AddressSpaceComposer
/// <param name="scriptedAlarms">The scripted alarms.</param> /// <param name="scriptedAlarms">The scripted alarms.</param>
/// <param name="virtualTags">The per-equipment virtual (calculated) tags. <c>null</c> = none.</param> /// <param name="virtualTags">The per-equipment virtual (calculated) tags. <c>null</c> = none.</param>
/// <param name="scripts">The scripts joined to <paramref name="virtualTags"/> by ScriptId for the expression. <c>null</c> = none.</param> /// <param name="scripts">The scripts joined to <paramref name="virtualTags"/> by ScriptId for the expression. <c>null</c> = none.</param>
/// <param name="unsTagReferences">The UNS tag references (equipment → raw tag). Feed the <c>{{equip}}/&lt;RefName&gt;</c> reference map. <c>null</c> = none.</param>
/// <param name="tags">The raw tags backing <paramref name="unsTagReferences"/> (RawPath computed via the shared resolver). <c>null</c> = none.</param>
/// <param name="rawFolders">The raw-tree folders (RawPath ancestry). <c>null</c> = none.</param>
/// <param name="devices">The raw-tree devices (RawPath ancestry). <c>null</c> = none.</param>
/// <param name="tagGroups">The raw-tree tag-groups (RawPath ancestry). <c>null</c> = none.</param>
/// <returns>The composition result.</returns> /// <returns>The composition result.</returns>
public static AddressSpaceComposition Compose( public static AddressSpaceComposition Compose(
IReadOnlyList<UnsArea> unsAreas, IReadOnlyList<UnsArea> unsAreas,
@@ -323,7 +489,12 @@ public static class AddressSpaceComposer
IReadOnlyList<DriverInstance> driverInstances, IReadOnlyList<DriverInstance> driverInstances,
IReadOnlyList<ScriptedAlarm> scriptedAlarms, IReadOnlyList<ScriptedAlarm> scriptedAlarms,
IReadOnlyList<VirtualTag>? virtualTags = null, IReadOnlyList<VirtualTag>? virtualTags = null,
IReadOnlyList<Script>? scripts = null) IReadOnlyList<Script>? scripts = null,
IReadOnlyList<UnsTagReference>? unsTagReferences = null,
IReadOnlyList<Tag>? tags = null,
IReadOnlyList<RawFolder>? rawFolders = null,
IReadOnlyList<Device>? devices = null,
IReadOnlyList<TagGroup>? tagGroups = null)
{ {
var vtags = virtualTags ?? Array.Empty<VirtualTag>(); var vtags = virtualTags ?? Array.Empty<VirtualTag>();
var resolvedScripts = scripts ?? Array.Empty<Script>(); var resolvedScripts = scripts ?? Array.Empty<Script>();
@@ -356,14 +527,25 @@ public static class AddressSpaceComposer
.Select(a => new ScriptedAlarmPlan(a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId, a.MessageTemplate)) .Select(a => new ScriptedAlarmPlan(a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId, a.MessageTemplate))
.ToList(); .ToList();
// v3 DARK: no equipment-tag variable plans this batch (Batch 4 owns UNS↔Raw fan-out). {{equip}} // v3: the retired equipment-namespace tag path stays empty — raw + UNS variable nodes now carry
// substitution therefore has no per-equipment tag base — matches the artifact-decode mirror. // every value (see RawTags / UnsReferenceVariables below).
var equipmentTags = Array.Empty<EquipmentTagPlan>(); var equipmentTags = Array.Empty<EquipmentTagPlan>();
var baseByEquip = new Dictionary<string, string?>(StringComparer.Ordinal);
// 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);
// Equipment VirtualTags = each VirtualTag joined to its Script (by ScriptId) for the // Equipment VirtualTags = each VirtualTag joined to its Script (by ScriptId) for the
// expression source. The {{equip}} token is substituted with the owning equipment's tag // expression source. Each {{equip}}/<RefName> is substituted with the owning equipment's backing
// base BEFORE extracting refs, so both Expression and DependencyRefs are machine-specific. // RawPath BEFORE extracting refs, so both Expression and DependencyRefs are machine-specific.
// DependencyRefs = the distinct ctx.GetTag("…") literals the VirtualTagActor subscribes to. // DependencyRefs = the distinct ctx.GetTag("…") literals the VirtualTagActor subscribes to.
// VirtualTag has no FolderPath today → "". // VirtualTag has no FolderPath today → "".
var scriptsById = resolvedScripts.ToDictionary(s => s.ScriptId, StringComparer.Ordinal); var scriptsById = resolvedScripts.ToDictionary(s => s.ScriptId, StringComparer.Ordinal);
@@ -374,7 +556,7 @@ public static class AddressSpaceComposer
{ {
var src = scriptsById.TryGetValue(v.ScriptId, out var s) ? s.SourceCode : string.Empty; var src = scriptsById.TryGetValue(v.ScriptId, out var s) ? s.SourceCode : string.Empty;
var expanded = EquipmentScriptPaths.SubstituteEquipmentToken( var expanded = EquipmentScriptPaths.SubstituteEquipmentToken(
src, baseByEquip.GetValueOrDefault(v.EquipmentId)); src, referenceMapByEquip.GetValueOrDefault(v.EquipmentId, emptyRefMap));
return new EquipmentVirtualTagPlan( return new EquipmentVirtualTagPlan(
VirtualTagId: v.VirtualTagId, VirtualTagId: v.VirtualTagId,
EquipmentId: v.EquipmentId, EquipmentId: v.EquipmentId,
@@ -412,7 +594,13 @@ public static class AddressSpaceComposer
a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId); a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId);
continue; continue;
} }
var source = s.SourceCode; // v3: scripted-alarm predicates ALSO resolve {{equip}}/<RefName> (equipment-scoped scripts),
// substituted with the owning equipment's reference map BEFORE the dependency merge — so the
// stored PredicateSource + DependencyRefs are resolved RawPaths, byte-parity with the
// artifact-decode mirror. The merge (predicate reads first, then template tokens) lives in the
// shared EquipmentScriptPaths helper.
var source = EquipmentScriptPaths.SubstituteEquipmentToken(
s.SourceCode, referenceMapByEquip.GetValueOrDefault(a.EquipmentId, emptyRefMap));
equipmentScriptedAlarms.Add(new EquipmentScriptedAlarmPlan( equipmentScriptedAlarms.Add(new EquipmentScriptedAlarmPlan(
ScriptedAlarmId: a.ScriptedAlarmId, ScriptedAlarmId: a.ScriptedAlarmId,
EquipmentId: a.EquipmentId, EquipmentId: a.EquipmentId,
@@ -422,21 +610,414 @@ public static class AddressSpaceComposer
MessageTemplate: a.MessageTemplate, MessageTemplate: a.MessageTemplate,
PredicateScriptId: a.PredicateScriptId, PredicateScriptId: a.PredicateScriptId,
PredicateSource: source, PredicateSource: source,
// Scripted alarms do NOT use {{equip}} substitution (only virtual tags do) — pass the
// predicate source as-is. The merge (predicate reads first, then template tokens) lives
// in the shared EquipmentScriptPaths helper so the artifact-decode mirror agrees.
DependencyRefs: EquipmentScriptPaths.ExtractAlarmDependencyRefs(source, a.MessageTemplate), DependencyRefs: EquipmentScriptPaths.ExtractAlarmDependencyRefs(source, a.MessageTemplate),
HistorizeToAveva: a.HistorizeToAveva, HistorizeToAveva: a.HistorizeToAveva,
Retain: a.Retain, Retain: a.Retain,
Enabled: a.Enabled)); 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) return new AddressSpaceComposition(areas, lines, nodes, plans, alarms)
{ {
EquipmentTags = equipmentTags, EquipmentTags = equipmentTags,
EquipmentVirtualTags = equipmentVirtualTags, EquipmentVirtualTags = equipmentVirtualTags,
EquipmentScriptedAlarms = equipmentScriptedAlarms, 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"/> 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,
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 tagsById = tagRows
.GroupBy(t => t.TagId, StringComparer.Ordinal)
.ToDictionary(
g => g.Key,
g => new EquipmentReferenceMap.TagRow(g.First().Name, g.First().DeviceId, g.First().TagGroupId),
StringComparer.Ordinal);
return EquipmentReferenceMap.Build(
refs.Select(r => new EquipmentReferenceMap.ReferenceRow(
r.UnsTagReferenceId, r.EquipmentId, r.TagId, r.DisplayNameOverride)),
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> /// </summary>
public IReadOnlyList<FolderRename> RenamedFolders { get; init; } = Array.Empty<FolderRename>(); 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> /// <summary>Gets a value indicating whether the composition plan contains no changes.</summary>
public bool IsEmpty => public bool IsEmpty =>
AddedEquipment.Count == 0 && RemovedEquipment.Count == 0 && ChangedEquipment.Count == 0 && 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 && AddedAlarms.Count == 0 && RemovedAlarms.Count == 0 && ChangedAlarms.Count == 0 &&
AddedEquipmentTags.Count == 0 && RemovedEquipmentTags.Count == 0 && ChangedEquipmentTags.Count == 0 && AddedEquipmentTags.Count == 0 && RemovedEquipmentTags.Count == 0 && ChangedEquipmentTags.Count == 0 &&
AddedEquipmentVirtualTags.Count == 0 && RemovedEquipmentVirtualTags.Count == 0 && ChangedEquipmentVirtualTags.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; RenamedFolders.Count == 0;
public sealed record EquipmentDelta(EquipmentNode Previous, EquipmentNode Current); 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 EquipmentTagDelta(EquipmentTagPlan Previous, EquipmentTagPlan Current);
public sealed record EquipmentVirtualTagDelta(EquipmentVirtualTagPlan Previous, EquipmentVirtualTagPlan 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 /// <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 /// <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"/> /// NodeId <c>MaterialiseHierarchy</c> placed the folder at) and the <paramref name="NewDisplayName"/>
@@ -139,6 +192,32 @@ public static class AddressSpacePlanner
t => t.VirtualTagId, t => t.VirtualTagId,
(a, b) => new AddressSpacePlan.EquipmentVirtualTagDelta(a, b)); (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 // 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 // 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 // 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, AddedEquipmentVirtualTags = addedVTags,
RemovedEquipmentVirtualTags = removedVTags, RemovedEquipmentVirtualTags = removedVTags,
ChangedEquipmentVirtualTags = changedVTags, ChangedEquipmentVirtualTags = changedVTags,
AddedRawContainers = addedRawContainers,
RemovedRawContainers = removedRawContainers,
ChangedRawContainers = changedRawContainers,
AddedRawTags = addedRawTags,
RemovedRawTags = removedRawTags,
ChangedRawTags = changedRawTags,
AddedUnsReferenceVariables = addedUnsRefs,
RemovedUnsReferenceVariables = removedUnsRefs,
ChangedUnsReferenceVariables = changedUnsRefs,
RenamedFolders = renamedFolders, RenamedFolders = renamedFolders,
}; };
} }
File diff suppressed because it is too large Load Diff
@@ -21,48 +21,56 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
} }
/// <inheritdoc /> /// <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)
=> _nodeManager.WriteValue(nodeId, value, quality, sourceTimestampUtc); => _nodeManager.WriteValue(nodeId, value, quality, sourceTimestampUtc, realm);
/// <inheritdoc /> /// <inheritdoc />
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
=> _nodeManager.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc); => _nodeManager.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm);
/// <inheritdoc /> /// <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)
=> _nodeManager.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative); => _nodeManager.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative);
/// <inheritdoc /> /// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
=> _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName); => _nodeManager.WireAlarmNotifiers(alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm);
/// <inheritdoc /> /// <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)
=> _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength); => _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName, realm);
/// <inheritdoc /> /// <inheritdoc />
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? 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.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength); => _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength);
/// <inheritdoc /> /// <inheritdoc />
public bool UpdateFolderDisplayName(string folderNodeId, string displayName) public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm)
=> _nodeManager.UpdateFolderDisplayName(folderNodeId, displayName); => _nodeManager.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength, realm);
/// <inheritdoc /> /// <inheritdoc />
public bool RemoveVariableNode(string variableNodeId) public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm)
=> _nodeManager.RemoveVariableNode(variableNodeId); => _nodeManager.UpdateFolderDisplayName(folderNodeId, displayName, realm);
/// <inheritdoc /> /// <inheritdoc />
public bool RemoveAlarmConditionNode(string alarmNodeId) public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm)
=> _nodeManager.RemoveAlarmConditionNode(alarmNodeId); => _nodeManager.RemoveVariableNode(variableNodeId, realm);
/// <inheritdoc /> /// <inheritdoc />
public bool RemoveEquipmentSubtree(string equipmentNodeId) public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm)
=> _nodeManager.RemoveEquipmentSubtree(equipmentNodeId); => _nodeManager.RemoveAlarmConditionNode(alarmNodeId, realm);
/// <inheritdoc />
public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm)
=> _nodeManager.RemoveEquipmentSubtree(equipmentNodeId, realm);
/// <inheritdoc /> /// <inheritdoc />
public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace(); public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace();
/// <inheritdoc /> /// <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 /> /// <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(); var driverHost = _resolveDriverHost();
if (driverHost is null) if (driverHost is null)
@@ -55,7 +55,7 @@ public sealed class ActorNodeWriteGateway : IOpcUaNodeWriteGateway
try try
{ {
var result = await driverHost.Ask<DriverHostActor.NodeWriteResult>( 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) if (!result.Success)
_logger.LogWarning("Operator write to {NodeId} rejected: {Reason}", nodeId, result.Reason); _logger.LogWarning("Operator write to {NodeId} rejected: {Reason}", nodeId, result.Reason);
return new NodeWriteOutcome(result.Success, result.Reason); return new NodeWriteOutcome(result.Success, result.Reason);
@@ -1,6 +1,7 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Commons.Types; 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.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.OpcUaServer; using ZB.MOM.WW.OtOpcUa.OpcUaServer;
@@ -177,6 +178,218 @@ public static class DeploymentArtifact
return byDriver; return byDriver;
} }
/// <summary>
/// Build the per-equipment <c>{{equip}}/&lt;RefName&gt;</c> reference map — <c>equipmentId →
/// (effectiveName → backing-tag RawPath)</c> — from the artifact's raw topology + <c>Tags</c> +
/// <c>UnsTagReferences</c> via the shared <see cref="EquipmentReferenceMap"/> + <see cref="RawPathResolver"/>.
/// The JSON-side mirror of <c>AddressSpaceComposer.BuildEquipmentReferenceMap</c> (entity side), so
/// both compose seams resolve <c>{{equip}}</c> script paths to byte-identical RawPaths. Empty when
/// the artifact carries no references / tags.
/// </summary>
/// <param name="root">The artifact root element.</param>
/// <returns><c>equipmentId → (effectiveName → RawPath)</c>.</returns>
private static IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> BuildEquipmentReferenceMap(JsonElement root)
{
var folders = new Dictionary<string, (string? ParentId, string Name)>(StringComparer.Ordinal);
foreach (var el in EnumerateArray(root, "RawFolders"))
{
var id = ReadString(el, "RawFolderId");
if (string.IsNullOrWhiteSpace(id)) continue;
folders[id!] = (ReadNullableString(el, "ParentRawFolderId"), ReadString(el, "Name") ?? id!);
}
var drivers = new Dictionary<string, (string? RawFolderId, string Name)>(StringComparer.Ordinal);
foreach (var el in EnumerateArray(root, "DriverInstances"))
{
var id = ReadString(el, "DriverInstanceId");
if (string.IsNullOrWhiteSpace(id)) continue;
drivers[id!] = (ReadNullableString(el, "RawFolderId"), ReadString(el, "Name") ?? id!);
}
var devices = new Dictionary<string, (string DriverInstanceId, string Name)>(StringComparer.Ordinal);
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[id!] = (di!, ReadString(el, "Name") ?? id!);
}
var groups = new Dictionary<string, (string? ParentId, string Name)>(StringComparer.Ordinal);
foreach (var el in EnumerateArray(root, "TagGroups"))
{
var id = ReadString(el, "TagGroupId");
if (string.IsNullOrWhiteSpace(id)) continue;
groups[id!] = (ReadNullableString(el, "ParentTagGroupId"), ReadString(el, "Name") ?? id!);
}
var resolver = new RawPathResolver(folders, drivers, devices, groups);
var tagsById = new Dictionary<string, EquipmentReferenceMap.TagRow>(StringComparer.Ordinal);
foreach (var el in EnumerateArray(root, "Tags"))
{
var tagId = ReadString(el, "TagId");
var deviceId = ReadString(el, "DeviceId");
var name = ReadString(el, "Name");
if (string.IsNullOrWhiteSpace(tagId) || string.IsNullOrWhiteSpace(deviceId) || string.IsNullOrWhiteSpace(name))
continue;
tagsById[tagId!] = new EquipmentReferenceMap.TagRow(name!, deviceId!, ReadNullableString(el, "TagGroupId"));
}
var references = new List<EquipmentReferenceMap.ReferenceRow>();
foreach (var el in EnumerateArray(root, "UnsTagReferences"))
{
var refId = ReadString(el, "UnsTagReferenceId");
var equipmentId = ReadString(el, "EquipmentId");
var tagId = ReadString(el, "TagId");
if (string.IsNullOrWhiteSpace(refId) || string.IsNullOrWhiteSpace(equipmentId) || string.IsNullOrWhiteSpace(tagId))
continue;
references.Add(new EquipmentReferenceMap.ReferenceRow(
refId!, equipmentId!, tagId!, ReadNullableString(el, "DisplayNameOverride")));
}
if (references.Count == 0 || tagsById.Count == 0)
return new Dictionary<string, IReadOnlyDictionary<string, string>>(StringComparer.Ordinal);
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 /// <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> /// the VirtualTag plan builder's join). Empty when the artifact carries no Scripts.</summary>
private static Dictionary<string, string> BuildScriptSourceMap(JsonElement root) private static Dictionary<string, string> BuildScriptSourceMap(JsonElement root)
@@ -400,14 +613,27 @@ public static class DeploymentArtifact
// deliberately empty; {{equip}} substitution therefore has no per-equipment tag base (empty). // deliberately empty; {{equip}} substitution therefore has no per-equipment tag base (empty).
// The retained per-equipment plans (folder hierarchy + VirtualTags + ScriptedAlarms) still exist. // The retained per-equipment plans (folder hierarchy + VirtualTags + ScriptedAlarms) still exist.
var equipmentTags = Array.Empty<EquipmentTagPlan>(); var equipmentTags = Array.Empty<EquipmentTagPlan>();
var equipmentVirtualTags = BuildEquipmentVirtualTagPlans(root, equipmentTags); // Per-equipment {{equip}}/<RefName> reference map (effectiveName → RawPath), shared by the
var equipmentScriptedAlarms = BuildEquipmentScriptedAlarmPlans(root); // VirtualTag + ScriptedAlarm plan builders so both substitute against the same resolved paths.
var referenceMapByEquip = BuildEquipmentReferenceMap(root);
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) return new AddressSpaceComposition(areas, lines, equipment, drivers, alarms)
{ {
EquipmentTags = equipmentTags, EquipmentTags = equipmentTags,
EquipmentVirtualTags = equipmentVirtualTags, EquipmentVirtualTags = equipmentVirtualTags,
EquipmentScriptedAlarms = equipmentScriptedAlarms, EquipmentScriptedAlarms = equipmentScriptedAlarms,
RawContainers = rawContainers,
RawTags = rawTags,
UnsReferenceVariables = unsReferenceVariables,
}; };
} }
catch (JsonException) catch (JsonException)
@@ -469,6 +695,13 @@ public static class DeploymentArtifact
EquipmentTags = full.EquipmentTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray(), EquipmentTags = full.EquipmentTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray(),
EquipmentVirtualTags = full.EquipmentVirtualTags.Where(v => sets.EquipmentIds.Contains(v.EquipmentId)).ToArray(), EquipmentVirtualTags = full.EquipmentVirtualTags.Where(v => sets.EquipmentIds.Contains(v.EquipmentId)).ToArray(),
EquipmentScriptedAlarms = full.EquipmentScriptedAlarms.Where(a => sets.EquipmentIds.Contains(a.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(),
}; };
} }
@@ -562,30 +795,22 @@ public static class DeploymentArtifact
/// Join the artifact's VirtualTags array to its Scripts array (by ScriptId) to emit one /// Join the artifact's VirtualTags array to its Scripts array (by ScriptId) to emit one
/// <see cref="EquipmentVirtualTagPlan"/> per VirtualTag. The artifact-decode mirror of /// <see cref="EquipmentVirtualTagPlan"/> per VirtualTag. The artifact-decode mirror of
/// <c>AddressSpaceComposer.Compose</c>'s VirtualTag producer — so the compose-side + artifact-decode /// <c>AddressSpaceComposer.Compose</c>'s VirtualTag producer — so the compose-side + artifact-decode
/// plans agree. The reserved <c>{{equip}}</c> token in the joined Script's <c>SourceCode</c> is /// plans agree. Each <c>{{equip}}/&lt;RefName&gt;</c> in the joined Script's <c>SourceCode</c> is
/// substituted with the owning equipment's tag base (derived from <paramref name="equipmentTags"/>' /// substituted with the owning equipment's backing RawPath (via <paramref name="referenceMapByEquip"/>)
/// FullNames) BEFORE refs are extracted, byte-parity with the composer. <c>Expression</c> = the /// BEFORE refs are extracted, byte-parity with the composer. <c>Expression</c> = the substituted
/// substituted source (empty when the ScriptId is absent); <c>DependencyRefs</c> = the distinct /// source (empty when the ScriptId is absent); <c>DependencyRefs</c> = the distinct
/// <c>ctx.GetTag("…")</c> literals in that source; <c>FolderPath</c> is always "" (VirtualTag has /// <c>ctx.GetTag("…")</c> literals in that source; <c>FolderPath</c> is always "" (VirtualTag has
/// no FolderPath today). Ordered by EquipmentId then Name to match the composer's deterministic /// no FolderPath today). Ordered by EquipmentId then Name to match the composer's deterministic
/// ordering. /// ordering.
/// </summary> /// </summary>
private static IReadOnlyList<EquipmentVirtualTagPlan> BuildEquipmentVirtualTagPlans( private static IReadOnlyList<EquipmentVirtualTagPlan> BuildEquipmentVirtualTagPlans(
JsonElement root, IReadOnlyList<EquipmentTagPlan> equipmentTags) JsonElement root, IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> referenceMapByEquip)
{ {
if (!root.TryGetProperty("VirtualTags", out var vtArr) || vtArr.ValueKind != JsonValueKind.Array) if (!root.TryGetProperty("VirtualTags", out var vtArr) || vtArr.ValueKind != JsonValueKind.Array)
return Array.Empty<EquipmentVirtualTagPlan>(); return Array.Empty<EquipmentVirtualTagPlan>();
// Per-equipment tag base = the shared substring-before-first-dot across each equipment's var emptyRefMap = (IReadOnlyDictionary<string, string>)
// child-tag FullNames, used to expand the reserved {{equip}} token in shared VirtualTag new Dictionary<string, string>(StringComparer.Ordinal);
// scripts (equipment-relative tag paths). Derived from equipmentTags so the artifact-decode
// base matches the composer's exactly.
var baseByEquip = equipmentTags
.GroupBy(t => t.EquipmentId, StringComparer.Ordinal)
.ToDictionary(
g => g.Key,
g => EquipmentScriptPaths.DeriveEquipmentBase(g.Select(t => t.FullName)),
StringComparer.Ordinal);
// scriptId → SourceCode (the expression source the VirtualTagActor evaluates). // scriptId → SourceCode (the expression source the VirtualTagActor evaluates).
var scriptSourceById = new Dictionary<string, string>(StringComparer.Ordinal); var scriptSourceById = new Dictionary<string, string>(StringComparer.Ordinal);
@@ -626,11 +851,11 @@ public static class DeploymentArtifact
&& (hEl.ValueKind == JsonValueKind.True || hEl.ValueKind == JsonValueKind.False) && (hEl.ValueKind == JsonValueKind.True || hEl.ValueKind == JsonValueKind.False)
&& hEl.GetBoolean(); && hEl.GetBoolean();
// Substitute the {{equip}} token with the owning equipment's tag base BEFORE extracting // Substitute each {{equip}}/<RefName> with the owning equipment's backing RawPath BEFORE
// refs, so both Expression and DependencyRefs are machine-specific — byte-parity with // extracting refs, so both Expression and DependencyRefs are machine-specific — byte-parity
// AddressSpaceComposer.Compose. // with AddressSpaceComposer.Compose.
var expanded = EquipmentScriptPaths.SubstituteEquipmentToken( var expanded = EquipmentScriptPaths.SubstituteEquipmentToken(
source, baseByEquip.GetValueOrDefault(equipmentId!)); source, referenceMapByEquip.GetValueOrDefault(equipmentId!, emptyRefMap));
result.Add(new EquipmentVirtualTagPlan( result.Add(new EquipmentVirtualTagPlan(
VirtualTagId: virtualTagId!, VirtualTagId: virtualTagId!,
@@ -659,11 +884,13 @@ public static class DeploymentArtifact
/// SKIPPED (matching the composer's skip behaviour) to preserve parity. <c>PredicateSource</c> = the /// SKIPPED (matching the composer's skip behaviour) to preserve parity. <c>PredicateSource</c> = the
/// joined script source ("" when missing — but such alarms are skipped above); <c>DependencyRefs</c> /// joined script source ("" when missing — but such alarms are skipped above); <c>DependencyRefs</c>
/// = the shared <see cref="EquipmentScriptPaths.ExtractAlarmDependencyRefs"/> merge of the predicate's /// = the shared <see cref="EquipmentScriptPaths.ExtractAlarmDependencyRefs"/> merge of the predicate's
/// distinct <c>ctx.GetTag("…")</c> reads UNION the message template's <c>{TagPath}</c> tokens. Scripted /// distinct <c>ctx.GetTag("…")</c> reads UNION the message template's <c>{TagPath}</c> tokens. The
/// alarms do NOT use <c>{{equip}}</c> substitution (only virtual tags do) — the predicate source is /// predicate source's <c>{{equip}}/&lt;RefName&gt;</c> paths are substituted with the owning equipment's
/// used as-is. Ordered by EquipmentId then ScriptedAlarmId to match the composer's deterministic order. /// backing RawPath (via <paramref name="referenceMapByEquip"/>) BEFORE the merge — byte-parity with
/// the composer. Ordered by EquipmentId then ScriptedAlarmId to match the composer's deterministic order.
/// </summary> /// </summary>
private static IReadOnlyList<EquipmentScriptedAlarmPlan> BuildEquipmentScriptedAlarmPlans(JsonElement root) private static IReadOnlyList<EquipmentScriptedAlarmPlan> BuildEquipmentScriptedAlarmPlans(
JsonElement root, IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> referenceMapByEquip)
{ {
if (!root.TryGetProperty("ScriptedAlarms", out var alarmsArr) || alarmsArr.ValueKind != JsonValueKind.Array) if (!root.TryGetProperty("ScriptedAlarms", out var alarmsArr) || alarmsArr.ValueKind != JsonValueKind.Array)
return Array.Empty<EquipmentScriptedAlarmPlan>(); return Array.Empty<EquipmentScriptedAlarmPlan>();
@@ -684,6 +911,8 @@ public static class DeploymentArtifact
} }
} }
var emptyRefMap = (IReadOnlyDictionary<string, string>)
new Dictionary<string, string>(StringComparer.Ordinal);
var result = new List<EquipmentScriptedAlarmPlan>(alarmsArr.GetArrayLength()); var result = new List<EquipmentScriptedAlarmPlan>(alarmsArr.GetArrayLength());
foreach (var el in alarmsArr.EnumerateArray()) foreach (var el in alarmsArr.EnumerateArray())
{ {
@@ -710,9 +939,14 @@ public static class DeploymentArtifact
// Skip alarms whose predicate script is missing — matching AddressSpaceComposer's skip behaviour // Skip alarms whose predicate script is missing — matching AddressSpaceComposer's skip behaviour
// so both sides emit the same set (byte-parity). // so both sides emit the same set (byte-parity).
if (predicateScriptId is null || !scriptSourceById.TryGetValue(predicateScriptId, out var source)) if (predicateScriptId is null || !scriptSourceById.TryGetValue(predicateScriptId, out var rawSource))
continue; continue;
// Substitute each {{equip}}/<RefName> with the owning equipment's backing RawPath BEFORE the
// dependency merge — byte-parity with AddressSpaceComposer.Compose.
var source = EquipmentScriptPaths.SubstituteEquipmentToken(
rawSource, referenceMapByEquip.GetValueOrDefault(equipmentId ?? string.Empty, emptyRefMap));
result.Add(new EquipmentScriptedAlarmPlan( result.Add(new EquipmentScriptedAlarmPlan(
ScriptedAlarmId: scriptedAlarmId!, ScriptedAlarmId: scriptedAlarmId!,
EquipmentId: equipmentId ?? string.Empty, EquipmentId: equipmentId ?? string.Empty,
@@ -1,4 +1,5 @@
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; 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.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.OpcUaServer; using ZB.MOM.WW.OtOpcUa.OpcUaServer;
@@ -36,8 +37,12 @@ public static class DiscoveredNodeMapper
/// </param> /// </param>
/// <returns>The folders, variables, and routing map to apply against the OPC UA address space.</returns> /// <returns>The folders, variables, and routing map to apply against the OPC UA address space.</returns>
public static DiscoveredInjectionPlan Map( 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(); 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 // 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++) for (var i = 0; i < segs.Count; i++)
{ {
var folderPath = string.Join('/', segs.Take(i + 1)); var folderPath = string.Join('/', segs.Take(i + 1));
var nodeId = EquipmentNodeIds.SubFolder(equipmentId, folderPath); var nodeId = Combine(rootNodeId, folderPath);
if (folders.ContainsKey(nodeId)) continue; 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]); folders[nodeId] = new DiscoveredFolder(nodeId, parent, segs[i]);
} }
var varFolderPath = string.Join('/', segs); var varFolderPath = string.Join('/', segs);
var varNodeId = EquipmentNodeIds.Variable(equipmentId, varFolderPath, n.BrowseName); var varNodeId = string.IsNullOrEmpty(varFolderPath)
// Mirror AddressSpaceApplier.MaterialiseEquipmentTags: a folder-less variable parents directly ? Combine(rootNodeId, n.BrowseName)
// at the equipment (SubFolder("", ...) would yield a trailing-slash "EQ-1/" that mismatches the : Combine(rootNodeId, varFolderPath + RawPaths.SeparatorString + n.BrowseName);
// EquipmentNodeIds.Variable NodeId, which guards IsNullOrWhiteSpace). // A folder-less variable parents directly at the root device node.
var varParent = string.IsNullOrEmpty(varFolderPath) var varParent = string.IsNullOrEmpty(varFolderPath)
? equipmentId ? rootNodeId
: EquipmentNodeIds.SubFolder(equipmentId, varFolderPath); : Combine(rootNodeId, varFolderPath);
variables.Add(new DiscoveredVariable( variables.Add(new DiscoveredVariable(
varNodeId, varParent, n.DisplayName, ToBuiltinTypeString(n.DataType), n.Writable, n.IsArray, n.ArrayDim)); varNodeId, varParent, n.DisplayName, ToBuiltinTypeString(n.DataType), n.Writable, n.IsArray, n.ArrayDim));
routing[n.FullReference] = varNodeId; 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. // spawn so a restart's respawn never collides with the still-terminating old child.
private long _childSpawnGeneration; private long _childSpawnGeneration;
/// <summary> /// <summary>A materialised NodeId together with the v3 <see cref="AddressSpaceRealm"/> it lives in, so the
/// Driver live-value routing map: <c>(DriverInstanceId, FullName) → folder-scoped equipment /// driver value fan-out posts each update to the sink with the right namespace. The same driver ref fans
/// NodeId(s)</c>. Rebuilt every apply by <see cref="PushDesiredSubscriptions"/> from the /// to its raw node (<see cref="AddressSpaceRealm.Raw"/>) and to every referencing UNS node
/// composition's <c>EquipmentTags</c> (mirroring <c>VirtualTagHostActor._nodeIdByVtag</c>), and /// (<see cref="AddressSpaceRealm.Uns"/>).</summary>
/// resolved in <see cref="ForwardToMux"/> so a driver value published by wire-ref FullName lands private readonly record struct NodeRealmRef(string NodeId, AddressSpaceRealm Realm);
/// 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> /// <summary>
/// Inverse of <see cref="_nodeIdByDriverRef"/>: <c>folder-scoped equipment NodeId → /// Driver live-value routing map: <c>(DriverInstanceId, RawPath) → materialised NodeId(s) (realm-tagged)</c>.
/// (DriverInstanceId, FullName)</c>. Rebuilt every apply by <see cref="PushDesiredSubscriptions"/> /// Rebuilt every apply by <see cref="PushDesiredSubscriptions"/> from the composition's <c>RawTags</c>
/// from the same <c>EquipmentTags</c> pass, and resolved by <see cref="HandleRouteNodeWrite"/> so an /// <c>UnsReferenceVariables</c>, and resolved in <see cref="ForwardToMux"/> so a driver value
/// inbound operator write targeting an equipment variable's NodeId is forwarded to the owning /// published by wire-ref RawPath fans to its raw node AND every referencing UNS node with identical
/// driver child as a write of its wire-ref <c>FullName</c>. Each NodeId maps to exactly one driver /// value/quality/timestamp — the single-source-fan-out (no independent buffer, so no drift). A set
/// ref (a variable is backed by a single driver attribute), so this is a flat 1:1 map (the forward /// because one driver ref can back the raw node plus N UNS references; the per-apply rebuild dedups.
/// map fans out 1:N because one ref can back several variables).
/// </summary> /// </summary>
private readonly Dictionary<string, (string DriverInstanceId, string RawPath)> _driverRefByNodeId = private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet<NodeRealmRef>> _nodeIdByDriverRef = new();
new(StringComparer.Ordinal);
/// <summary>(DriverInstanceId, FullName = alarm ConditionId / AlarmFullReference) → folder-scoped condition NodeId(s). /// <summary>
/// Built from EquipmentTags whose plan carries Alarm, alongside the value maps; resolves a native /// Inverse of <see cref="_nodeIdByDriverRef"/>: <c>(Realm, BARE <c>s=</c> id) → (DriverInstanceId,
/// alarm transition to the materialised Part 9 condition node(s). Alarm tags are conditions, not /// RawPath)</c>. v3 Batch 4: keyed by BOTH the raw NodeId (<see cref="AddressSpaceRealm.Raw"/>) AND every
/// value variables, so they are kept OUT of the value maps + value-subscription set.</summary> /// referencing UNS NodeId (<see cref="AddressSpaceRealm.Uns"/>) — all mapping to the same driver ref
private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet<string>> _alarmNodeIdByDriverRef = new(); /// (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> /// <summary>
/// Inverse of <see cref="_alarmNodeIdByDriverRef"/>: <c>folder-scoped condition NodeId → /// 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 /// <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 /// 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> /// 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); new(StringComparer.Ordinal);
/// <summary>Derives a full Part 9 condition snapshot from each native alarm transition delta, /// <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> /// </summary>
/// <param name="NodeId">The folder-scoped equipment-variable NodeId the operator wrote to.</param> /// <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> /// <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 /// <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> /// (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. // equipment variables (identical machines sharing a register), hence the fan-out.
if (_nodeIdByDriverRef.TryGetValue((msg.DriverInstanceId, msg.FullReference), out var nodeIds)) 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( _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 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); _localNode, msg.DriverInstanceId, msg.FullReference);
} }
} }
@@ -643,6 +657,21 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// </summary> /// </summary>
private void HandleDiscoveredNodes(DriverInstanceActor.DiscoveredNodesReady msg) 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) if (_lastComposition is null)
{ {
_log.Debug("DriverHost {Node}: DiscoveredNodesReady from {Driver} before any composition applied — ignored", _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; _discoveredByDriver[msg.DriverInstanceId] = newPlans;
ApplyDiscoveredPlansForDriver(msg.DriverInstanceId, newPlans); ApplyDiscoveredPlansForDriver(msg.DriverInstanceId, newPlans);
#pragma warning restore CS0162
} }
/// <summary> /// <summary>
@@ -918,9 +948,11 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
{ {
var key = (driverId, driverRef); var key = (driverId, driverRef);
if (!_nodeIdByDriverRef.TryGetValue(key, out var set)) if (!_nodeIdByDriverRef.TryGetValue(key, out var set))
_nodeIdByDriverRef[key] = set = new HashSet<string>(StringComparer.Ordinal); _nodeIdByDriverRef[key] = set = new HashSet<NodeRealmRef>();
set.Add(nodeId); // v3 Batch 4: discovered (FixedTree) nodes graft onto the RAW device subtree, so they route
_driverRefByNodeId[nodeId] = key; // 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( _opcUaPublishActor?.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.MaterialiseDiscoveredNodes(
equipmentId, plan.Folders, plan.Variables)); equipmentId, plan.Folders, plan.Variables));
@@ -1005,17 +1037,19 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
_localNode, msg.DriverInstanceId, msg.Args.ConditionId); _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); var snapshot = _nativeAlarmProjector.Project(nodeId, msg.Args);
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AlarmStateUpdate( _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). // Only the Primary publishes the cluster-wide alerts transition (see the gate above).
if (!serviceAlertsAsPrimary) continue; if (!serviceAlertsAsPrimary) continue;
var meta = _alarmMetaByNodeId.TryGetValue(nodeId, out var m) 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( _mediator.Tell(new Publish(ScriptedAlarmHostActor.AlertsTopic, new AlarmTransitionEvent(
AlarmId: nodeId, AlarmId: nodeId,
EquipmentPath: meta.EquipmentId, EquipmentPath: meta.EquipmentId,
@@ -1035,7 +1069,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
// historize). The HistorianAdapterActor gate (historizeToAveva is not false) historizes null + // 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 // 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). // 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; 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; return;
} }
@@ -1115,6 +1158,25 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
.PipeTo(replyTo); .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> /// <summary>
/// Routes an inbound native-condition acknowledge (the host Tells this from the OPC UA node-manager /// 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 /// 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; return;
} }
// Value-subscription set: alarm-bearing tags are Part 9 conditions, not value variables, so they // v3 Batch 4: the driver subscribes + publishes by RawPath (the wire-ref == RawPath == RawTagPlan.NodeId
// are excluded — the driver must not value-subscribe an alarm attribute (it is fed via the native // per the v3 identity contract). Value-subscription set = the non-alarm raw tags' RawPaths; alarm-bearing
// alarm event stream, routed by ForwardNativeAlarm). // raw tags are Part 9 conditions, fed via the native alarm event stream (ForwardNativeAlarm), so they are
var refsByDriver = composition.EquipmentTags // excluded from the value set.
var refsByDriver = composition.RawTags
.Where(t => t.Alarm is null) .Where(t => t.Alarm is null)
.GroupBy(t => t.DriverInstanceId, StringComparer.Ordinal) .GroupBy(t => t.DriverInstanceId, StringComparer.Ordinal)
.ToDictionary( .ToDictionary(
g => g.Key, g => g.Key,
g => (IReadOnlyList<string>)g.Select(t => t.FullName) g => (IReadOnlyList<string>)g.Select(t => t.NodeId)
.Distinct(StringComparer.Ordinal) .Distinct(StringComparer.Ordinal)
.ToArray(), .ToArray(),
StringComparer.Ordinal); StringComparer.Ordinal);
// Native-alarm subscription set: the alarm-bearing tags' FullNames (= the driver's // Native-alarm subscription set: the alarm-bearing raw tags' RawPaths (= the driver's ConditionId).
// ConditionId/AlarmFullReference). An IAlarmSource driver suppresses OnAlarmEvent until at least one // An IAlarmSource driver suppresses OnAlarmEvent until at least one alarm subscription exists, so the
// 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
// instance actor must SubscribeAlarmsAsync these refs to un-gate the feed. Routing stays by // in ForwardNativeAlarm; this set just opens (and scopes) the subscription.
// ConditionId in ForwardNativeAlarm; this set just opens (and scopes) the subscription. var alarmRefsByDriver = composition.RawTags
var alarmRefsByDriver = composition.EquipmentTags
.Where(t => t.Alarm is not null) .Where(t => t.Alarm is not null)
.GroupBy(t => t.DriverInstanceId, StringComparer.Ordinal) .GroupBy(t => t.DriverInstanceId, StringComparer.Ordinal)
.ToDictionary( .ToDictionary(
g => g.Key, g => g.Key,
g => (IReadOnlyList<string>)g.Select(t => t.FullName) g => (IReadOnlyList<string>)g.Select(t => t.NodeId)
.Distinct(StringComparer.Ordinal) .Distinct(StringComparer.Ordinal)
.ToArray(), .ToArray(),
StringComparer.Ordinal); StringComparer.Ordinal);
// Rebuild the driver live-value routing map from the SAME EquipmentTags pass (mirrors // Referencing UNS nodes by backing RawPath — each raw value tag fans out to its own raw NodeId PLUS
// VirtualTagHostActor._nodeIdByVtag): map each tag's (DriverInstanceId, FullName) wire-ref to // every UNS reference that projects it (dual-NodeId registration against the SAME driver ref).
// the folder-scoped equipment NodeId the materialiser placed its variable at, so ForwardToMux var unsRefsByRawPath = new Dictionary<string, List<UnsReferenceVariable>>(StringComparer.Ordinal);
// can land driver values on the right node. Clear-and-repopulate every apply so renames foreach (var v in composition.UnsReferenceVariables)
// (Name/FolderPath/EquipmentId changes) and removals are reflected. {
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(); _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(); _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(); _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(); _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(); _alarmMetaByNodeId.Clear();
_nativeAlarmProjector.Clear(); _nativeAlarmProjector.Clear();
foreach (var t in composition.EquipmentTags) foreach (var t in composition.RawTags)
{ {
var key = (t.DriverInstanceId, t.FullName); var key = (t.DriverInstanceId, t.NodeId); // (DriverInstanceId, RawPath) — RawPath IS the wire-ref
var nodeId = EquipmentNodeIds.Variable(t.EquipmentId, t.FolderPath, t.Name);
if (t.Alarm is not null) if (t.Alarm is not null)
{ {
// Alarm tags are conditions, not value variables: route them ONLY into the alarm map and // Native alarm → a single Part 9 condition at the RawPath (Raw realm). WP4 adds the referencing
// keep them OUT of the value maps + value-subscription set (so they don't get both a value // equipment notifier NodeIds; WP3 wires the single raw condition.
// variable AND a condition).
if (!_alarmNodeIdByDriverRef.TryGetValue(key, out var aset)) if (!_alarmNodeIdByDriverRef.TryGetValue(key, out var aset))
_alarmNodeIdByDriverRef[key] = aset = new HashSet<string>(StringComparer.Ordinal); _alarmNodeIdByDriverRef[key] = aset = new HashSet<NodeRealmRef>();
aset.Add(nodeId); aset.Add(new NodeRealmRef(t.NodeId, AddressSpaceRealm.Raw));
// Inverse 1:1 map for the inbound native-condition ack path: the materialised condition // Inverse 1:1 map for the inbound native-condition ack path (keyed by BARE condition NodeId).
// NodeId routes back to the owning (DriverInstanceId, FullName=ConditionId) so an OPC UA _driverRefByAlarmNodeId[t.NodeId] = key;
// acknowledge of this condition reaches the right driver child. // Per-condition metadata for the alerts fan-out. WP4 refines /alerts identity to the referencing
_driverRefByAlarmNodeId[nodeId] = key; // equipment paths; WP3 keys the display off the RawPath. The referencing-equipment paths (the
// Capture the per-condition metadata the alerts fan-out (ForwardNativeAlarm) needs to build // Area/Line/Equipment UNS folder paths carried on the RawTagPlan) ride onto every transition so
// the AlarmTransitionEvent: the equipment path, the operator-visible alarm name, and the // the /alerts row lists which equipment reference this raw condition.
// OPC UA Part 9 subtype. Keyed by the condition NodeId (the projection's own key). _alarmMetaByNodeId[t.NodeId] = (t.NodeId, t.Name, t.Alarm.AlarmType, t.Alarm.HistorizeToAveva, t.ReferencingEquipmentPaths);
_alarmMetaByNodeId[nodeId] = (t.EquipmentId, t.Name, t.Alarm.AlarmType, t.Alarm.HistorizeToAveva);
continue; 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)) if (!_nodeIdByDriverRef.TryGetValue(key, out var set))
_nodeIdByDriverRef[key] = set = new HashSet<string>(StringComparer.Ordinal); _nodeIdByDriverRef[key] = set = new HashSet<NodeRealmRef>();
set.Add(nodeId); set.Add(new NodeRealmRef(t.NodeId, AddressSpaceRealm.Raw));
_driverRefByNodeId[nodeId] = key; _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 // Snapshot the cached (FixedTree-discovered) driver set BEFORE the bulk loop, while _discoveredByDriver
@@ -40,8 +40,10 @@ public sealed class ServerHistorianOptions
/// <summary> /// <summary>
/// The peppered-HMAC API key (<c>histgw_&lt;id&gt;_&lt;secret&gt;</c>) the gateway validates /// 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 /// in the <c>Authorization: Bearer</c> header. Supply via the environment variable
/// <c>ServerHistorian__ApiKey</c> — never commit it to config. Required when /// <c>ServerHistorian__ApiKey</c> — never commit it to config. The value may be a literal
/// <see cref="Enabled"/> is <c>true</c>. /// 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> /// </summary>
public string ApiKey { get; init; } = ""; 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> /// 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() { } } 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 /// <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 /// <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 /// Core <c>AlarmConditionState</c> + severity/message — the actor stays decoupled from
/// <c>Core.ScriptedAlarms</c>.</summary> /// <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="State">The full condition state to project onto the node.</param>
/// <param name="TimestampUtc">The source timestamp of the transition in UTC.</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> /// <summary>
/// Triggers an address-space rebuild. <paramref name="DeploymentId"/> is the deployment /// 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 /// 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 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); Interlocked.Increment(ref _writes);
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "value")); OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "value"));
} }
@@ -277,7 +286,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
{ {
try try
{ {
_sink.WriteAlarmCondition(msg.AlarmNodeId, msg.State, msg.TimestampUtc); _sink.WriteAlarmCondition(msg.AlarmNodeId, msg.State, msg.TimestampUtc, msg.Realm);
Interlocked.Increment(ref _writes); Interlocked.Increment(ref _writes);
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "alarm")); 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. // Warnings + an optimistic Info line; now it surfaces at Error + a dedicated meter.
var failedNodes = outcome.FailedNodes; var failedNodes = outcome.FailedNodes;
failedNodes += _applier.MaterialiseHierarchy(composition); 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 // 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 // folders they parent under already exist. Materialises real Part 9 AlarmConditionState
// nodes (keyed by ScriptedAlarmId so AlarmStateUpdate writes target them); disabled // 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/ // 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. // 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). // 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( _publishActor.Tell(new OpcUaPublishActor.AlarmStateUpdate(
AlarmNodeId: e.AlarmId, AlarmNodeId: e.AlarmId,
State: ToSnapshot(e), State: ToSnapshot(e),
TimestampUtc: e.TimestampUtc)); TimestampUtc: e.TimestampUtc,
Realm: AddressSpaceRealm.Uns));
// Publish the transition to the cluster `alerts` topic — the single historization + live // 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. // 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); 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 /// <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, /// 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 /// <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 /// <paramref name="Value"/> is the last-known value (or null if none). Defaulting to Good keeps
/// every existing construction site source-compatible.</summary> /// 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 _virtualTagId;
private readonly string _scriptId; private readonly string _scriptId;
@@ -105,6 +117,7 @@ public sealed class VirtualTagActor : ReceiveActor
_mux = mux; _mux = mux;
Receive<DependencyValueChanged>(OnDependencyChanged); Receive<DependencyValueChanged>(OnDependencyChanged);
Receive<ReassertValue>(_ => OnReassertValue());
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -122,6 +135,32 @@ public sealed class VirtualTagActor : ReceiveActor
_mux?.Tell(new DependencyMuxActor.UnregisterInterest(Self)); _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) private void OnDependencyChanged(DependencyValueChanged msg)
{ {
_dependencies[msg.TagId] = msg.Value; _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. /// already-materialised Variable node (currently BadWaitingForInitialData) reflects the value.
/// ///
/// <para> /// <para>
/// The published NodeId is computed by the shared <see cref="EquipmentNodeIds.Variable"/> — /// The published NodeId is computed via <see cref="V3NodeIds.Uns(string[])"/> (the equipment-anchored
/// the single source of truth <c>AddressSpaceApplier.MaterialiseEquipmentVirtualTags</c> also /// slash path <c>{EquipmentId}[/{FolderPath}]/{Name}</c>) — byte-identical to the NodeId
/// materialises against — so the value always lands on a NodeId that exists. /// <c>AddressSpaceApplier.MaterialiseEquipmentVirtualTags</c> materialises against — so the value
/// always lands on a NodeId that exists.
/// </para> /// </para>
/// </summary> /// </summary>
public sealed class VirtualTagHostActor : ReceiveActor 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 // 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 // whose plan was unchanged still have a live entry and are skipped, keeping their mux
// subscriptions and last-value dedup state. // 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) foreach (var p in msg.Plans)
{ {
if (_children.ContainsKey(p.VirtualTagId)) continue; if (_children.ContainsKey(p.VirtualTagId)) continue;
@@ -165,6 +169,7 @@ public sealed class VirtualTagHostActor : ReceiveActor
mux: _mux)); mux: _mux));
Context.Watch(child); Context.Watch(child);
_children[p.VirtualTagId] = child; _children[p.VirtualTagId] = child;
newlySpawned.Add(p.VirtualTagId);
_log.Debug("VirtualTagHost: spawned child for vtag {VirtualTagId}", 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; _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})", _log.Debug("VirtualTagHost: applied (desired={Desired}, children={Children})",
desired.Count, _children.Count); 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 // 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 // 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. // 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( _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 // Historize iff the plan opted in — but NEVER for a re-assert (M1). A re-assert re-publishes a
// no parallel map. The historian path key is the SAME folder-scoped NodeId we just published // stale last value stamped with a fresh deploy-time timestamp purely to repair the reset OPC UA
// to. For a computed value source == server, so both timestamps are the evaluation time. Bad // node; recording it would append an artificial historian sample that never corresponded to a real
// results record BadInternalError (0x80020000u) — the dormant VirtualTagEngine's Bad-snapshot // evaluation (and, if the last state was Bad, a fabricated BadInternalError point). Reuses
// status — so the historian trend can distinguish a degraded sample from a Good one. // _planByVtag (kept in lock-step with _children), so no parallel map. The historian path key is the
if (_planByVtag.TryGetValue(result.VirtualTagId, out var plan) && plan.Historize) // 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 */; var status = result.Quality == OpcUaQuality.Good ? 0u : 0x80020000u /* BadInternalError — dormant-engine parity */;
_history.Record(nodeId, new DataValueSnapshot( _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 /// <summary>UNS-realm NodeId for a VirtualTag plan — the equipment-anchored slash path
/// <see cref="EquipmentNodeIds"/> (the single source of truth that <c>AddressSpaceApplier</c> also /// <c>{EquipmentId}[/{FolderPath}]/{Name}</c>, expressed through the v3 identity authority
/// materialises against), so the published value always lands on the NodeId that was materialised.</summary> /// <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) => 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));
} }
@@ -6,6 +6,18 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Tests;
public class EquipmentScriptPathsTests public class EquipmentScriptPathsTests
{ {
// Effective-name → backing-tag RawPath map used across the substitution tests.
private static readonly IReadOnlyDictionary<string, string> RefMap =
new Dictionary<string, string>(StringComparer.Ordinal)
{
["Speed"] = "Cell1/Modbus/Dev1/Speed",
["Out"] = "Cell1/Modbus/Dev1/Out",
["A"] = "Cell1/Modbus/Dev1/A",
["B"] = "Cell1/Modbus/Dev1/B",
// an override-named reference: the effective name differs from the backing tag leaf name.
["MotorSpeed"] = "Cell1/Modbus/Dev1/raw_speed_01",
};
// ---- ExtractAlarmDependencyRefs (scripted-alarm dep graph; byte-parity seam) ---- // ---- ExtractAlarmDependencyRefs (scripted-alarm dep graph; byte-parity seam) ----
[Fact] [Fact]
@@ -33,123 +45,81 @@ public class EquipmentScriptPathsTests
.ShouldBe(["Line.Temp"]); .ShouldBe(["Line.Temp"]);
} }
// ---- DeriveEquipmentBase ---- // ---- SubstituteEquipmentToken (v3 reference-relative, slash syntax) ----
[Fact] [Fact]
public void DeriveEquipmentBase_returns_shared_prefix() public void SubstituteEquipmentToken_resolves_ref_to_rawpath_inside_GetTag()
{ {
EquipmentScriptPaths EquipmentScriptPaths.SubstituteEquipmentToken(
.DeriveEquipmentBase(["TestMachine_001.A", "TestMachine_001.B"]) "ctx.GetTag(\"{{equip}}/Speed\")", RefMap)
.ShouldBe("TestMachine_001"); .ShouldBe("ctx.GetTag(\"Cell1/Modbus/Dev1/Speed\")");
} }
[Fact] [Fact]
public void DeriveEquipmentBase_returns_null_when_prefixes_diverge() public void SubstituteEquipmentToken_resolves_ref_inside_SetVirtualTag()
{ {
EquipmentScriptPaths EquipmentScriptPaths.SubstituteEquipmentToken(
.DeriveEquipmentBase(["TestMachine_001.A", "DelmiaReceiver_001.B"]) "ctx.SetVirtualTag(\"{{equip}}/Out\", x)", RefMap)
.ShouldBeNull(); .ShouldBe("ctx.SetVirtualTag(\"Cell1/Modbus/Dev1/Out\", x)");
} }
[Fact] [Fact]
public void DeriveEquipmentBase_returns_null_for_empty_sequence() public void SubstituteEquipmentToken_override_named_ref_resolves_to_its_backing_rawpath()
{ {
EquipmentScriptPaths // Effective name "MotorSpeed" (a DisplayNameOverride) resolves to the backing tag's RawPath whose
.DeriveEquipmentBase([]) // leaf name ("raw_speed_01") differs from the effective name — the override indirection.
.ShouldBeNull(); EquipmentScriptPaths.SubstituteEquipmentToken(
"ctx.GetTag(\"{{equip}}/MotorSpeed\")", RefMap)
.ShouldBe("ctx.GetTag(\"Cell1/Modbus/Dev1/raw_speed_01\")");
} }
[Fact] [Fact]
public void DeriveEquipmentBase_returns_whole_value_when_no_dot() public void SubstituteEquipmentToken_leaves_unresolved_ref_intact_without_throwing()
{ {
EquipmentScriptPaths // An unknown reference name is left un-substituted (the validator rejects it first at deploy).
.DeriveEquipmentBase(["NoDot"]) var source = "ctx.GetTag(\"{{equip}}/Missing\")";
.ShouldBe("NoDot"); EquipmentScriptPaths.SubstituteEquipmentToken(source, RefMap).ShouldBe(source);
}
[Fact]
public void DeriveEquipmentBase_skips_null_empty_and_whitespace_entries()
{
EquipmentScriptPaths
.DeriveEquipmentBase([null, "", " ", "TestMachine_001.A"])
.ShouldBe("TestMachine_001");
}
// ---- SubstituteEquipmentToken ----
[Fact]
public void SubstituteEquipmentToken_substitutes_inside_GetTag()
{
var result = EquipmentScriptPaths.SubstituteEquipmentToken(
"ctx.GetTag(\"{{equip}}.Source\")", "TestMachine_001");
result.ShouldContain("ctx.GetTag(\"TestMachine_001.Source\")");
}
[Fact]
public void SubstituteEquipmentToken_substitutes_inside_SetVirtualTag()
{
var result = EquipmentScriptPaths.SubstituteEquipmentToken(
"ctx.SetVirtualTag(\"{{equip}}.Out\", x)", "TestMachine_001");
result.ShouldContain("ctx.SetVirtualTag(\"TestMachine_001.Out\"");
} }
[Fact] [Fact]
public void SubstituteEquipmentToken_leaves_comment_unchanged() public void SubstituteEquipmentToken_leaves_comment_unchanged()
{ {
var source = "// uses {{equip}} here"; var source = "// uses {{equip}}/Speed here";
EquipmentScriptPaths.SubstituteEquipmentToken(source, RefMap).ShouldBe(source);
EquipmentScriptPaths.SubstituteEquipmentToken(source, "TestMachine_001")
.ShouldBe(source);
} }
[Fact] [Fact]
public void SubstituteEquipmentToken_leaves_logger_string_unchanged() public void SubstituteEquipmentToken_leaves_logger_string_unchanged()
{ {
var source = "ctx.Logger.Information(\"{{equip}}\")"; var source = "ctx.Logger.Information(\"{{equip}}/Speed\")";
EquipmentScriptPaths.SubstituteEquipmentToken(source, RefMap).ShouldBe(source);
EquipmentScriptPaths.SubstituteEquipmentToken(source, "TestMachine_001")
.ShouldBe(source);
} }
[Fact] [Fact]
public void SubstituteEquipmentToken_substitutes_all_occurrences() public void SubstituteEquipmentToken_substitutes_all_occurrences()
{ {
var source = "ctx.GetTag(\"{{equip}}.A\"); ctx.GetTag(\"{{equip}}.B\");"; var source = "ctx.GetTag(\"{{equip}}/A\"); ctx.GetTag(\"{{equip}}/B\");";
var result = EquipmentScriptPaths.SubstituteEquipmentToken(source, RefMap);
var result = EquipmentScriptPaths.SubstituteEquipmentToken(source, "TestMachine_001"); result.ShouldContain("ctx.GetTag(\"Cell1/Modbus/Dev1/A\")");
result.ShouldContain("ctx.GetTag(\"Cell1/Modbus/Dev1/B\")");
result.ShouldContain("ctx.GetTag(\"TestMachine_001.A\")");
result.ShouldContain("ctx.GetTag(\"TestMachine_001.B\")");
result.ShouldNotContain(EquipmentScriptPaths.EquipToken); result.ShouldNotContain(EquipmentScriptPaths.EquipToken);
} }
[Fact] [Fact]
public void SubstituteEquipmentToken_is_identity_when_equipBase_is_null() public void SubstituteEquipmentToken_is_identity_when_map_empty()
{ {
var source = "ctx.GetTag(\"{{equip}}.Source\")"; var source = "ctx.GetTag(\"{{equip}}/Speed\")";
EquipmentScriptPaths.SubstituteEquipmentToken(
EquipmentScriptPaths.SubstituteEquipmentToken(source, null) source, new Dictionary<string, string>(StringComparer.Ordinal))
.ShouldBe(source);
}
[Fact]
public void SubstituteEquipmentToken_is_identity_when_equipBase_is_empty()
{
var source = "ctx.GetTag(\"{{equip}}.Source\")";
EquipmentScriptPaths.SubstituteEquipmentToken(source, "")
.ShouldBe(source); .ShouldBe(source);
} }
[Fact] [Fact]
public void SubstituteEquipmentToken_is_identity_when_token_absent() public void SubstituteEquipmentToken_is_identity_when_token_absent()
{ {
var source = "ctx.GetTag(\"TestMachine_001.Source\")"; var source = "ctx.GetTag(\"Cell1/Modbus/Dev1/Speed\")";
EquipmentScriptPaths.SubstituteEquipmentToken(source, RefMap).ShouldBe(source);
EquipmentScriptPaths.SubstituteEquipmentToken(source, "TestMachine_001")
.ShouldBe(source);
} }
[Fact] [Fact]
@@ -157,10 +127,59 @@ public class EquipmentScriptPathsTests
{ {
// Documents the regex limitation: only "double-quote" literals are handled, // Documents the regex limitation: only "double-quote" literals are handled,
// matching the existing seam extractors. A raw-string literal is left as-is. // matching the existing seam extractors. A raw-string literal is left as-is.
var source = "ctx.GetTag(\"\"\"{{equip}}.X\"\"\")"; var source = "ctx.GetTag(\"\"\"{{equip}}/Speed\"\"\")";
EquipmentScriptPaths.SubstituteEquipmentToken(source, RefMap).ShouldBe(source);
}
EquipmentScriptPaths.SubstituteEquipmentToken(source, "TestMachine_001") // ---- ExtractEquipReferenceNames (script path-literal scoped) ----
.ShouldBe(source);
[Fact]
public void ExtractEquipReferenceNames_returns_distinct_refs_first_seen()
{
var source = "ctx.GetTag(\"{{equip}}/Speed\"); ctx.SetVirtualTag(\"{{equip}}/Out\", x); ctx.GetTag(\"{{equip}}/Speed\");";
EquipmentScriptPaths.ExtractEquipReferenceNames(source).ShouldBe(["Speed", "Out"]);
}
[Fact]
public void ExtractEquipReferenceNames_ignores_token_in_comment_or_logger()
{
// Only ctx.GetTag/SetVirtualTag path literals are scanned — a token in a comment / logger string
// is NOT reported (so it can never block a deploy).
var source = "// {{equip}}/InComment\nctx.Logger.Information(\"{{equip}}/InLog\"); ctx.GetTag(\"{{equip}}/Real\");";
EquipmentScriptPaths.ExtractEquipReferenceNames(source).ShouldBe(["Real"]);
}
[Fact]
public void ExtractEquipReferenceNames_supports_ref_names_with_spaces()
{
// The whole post-prefix literal content is the reference name — so a space-bearing effective name
// (valid raw name) is captured intact, matching what substitution resolves.
EquipmentScriptPaths.ExtractEquipReferenceNames("ctx.GetTag(\"{{equip}}/My Tag\")")
.ShouldBe(["My Tag"]);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("ctx.GetTag(\"Absolute/Path\")")]
public void ExtractEquipReferenceNames_empty_when_no_token(string? source)
{
EquipmentScriptPaths.ExtractEquipReferenceNames(source).ShouldBeEmpty();
}
// ---- ExtractEquipReferenceNamesFromText (message-template free text) ----
[Fact]
public void ExtractEquipReferenceNamesFromText_captures_refs_bounded_by_whitespace()
{
EquipmentScriptPaths.ExtractEquipReferenceNamesFromText("Temp {{equip}}/Speed exceeded on {{equip}}/Limit")
.ShouldBe(["Speed", "Limit"]);
}
[Fact]
public void ExtractEquipReferenceNamesFromText_empty_when_no_token()
{
EquipmentScriptPaths.ExtractEquipReferenceNamesFromText("plain message {Foo.Bar}").ShouldBeEmpty();
} }
// ---- ExtractDependencyRefs ---- // ---- ExtractDependencyRefs ----
@@ -197,7 +216,7 @@ public class EquipmentScriptPathsTests
[Fact] [Fact]
public void ContainsEquipToken_true_when_token_present() public void ContainsEquipToken_true_when_token_present()
{ {
EquipmentScriptPaths.ContainsEquipToken("ctx.GetTag(\"{{equip}}.X\")").ShouldBeTrue(); EquipmentScriptPaths.ContainsEquipToken("ctx.GetTag(\"{{equip}}/X\")").ShouldBeTrue();
} }
[Fact] [Fact]
@@ -217,7 +236,7 @@ public class EquipmentScriptPathsTests
[Theory] [Theory]
[InlineData("return ctx.GetTag(\"TestMachine_020.TestChangingInt\").Value;", "TestMachine_020.TestChangingInt")] [InlineData("return ctx.GetTag(\"TestMachine_020.TestChangingInt\").Value;", "TestMachine_020.TestChangingInt")]
[InlineData(" return ctx . GetTag ( \"A.B\" ) . Value ; ", "A.B")] [InlineData(" return ctx . GetTag ( \"A.B\" ) . Value ; ", "A.B")]
[InlineData("return ctx.GetTag(\"{{equip}}.Speed\").Value;", "{{equip}}.Speed")] [InlineData("return ctx.GetTag(\"{{equip}}/Speed\").Value;", "{{equip}}/Speed")]
public void TryParseRelayBody_accepts_exact_passthrough(string src, string expectedRef) public void TryParseRelayBody_accepts_exact_passthrough(string src, string expectedRef)
{ {
EquipmentScriptPaths.TryParseRelayBody(src, out var r).ShouldBeTrue(); EquipmentScriptPaths.TryParseRelayBody(src, out var r).ShouldBeTrue();
@@ -19,7 +19,7 @@ public class DeferredAddressSpaceSinkTests
{ {
var sink = new DeferredAddressSpaceSink(); var sink = new DeferredAddressSpaceSink();
// Must not throw. // 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] [Fact]
@@ -34,7 +34,7 @@ public class DeferredAddressSpaceSinkTests
{ {
var sink = new DeferredAddressSpaceSink(); var sink = new DeferredAddressSpaceSink();
sink.UpdateTagAttributes("ns=2;s=x", writable: true, historianTagname: null, 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 ---------- // ---------- after SetSink — operations are forwarded ----------
@@ -46,7 +46,7 @@ public class DeferredAddressSpaceSinkTests
var sink = new DeferredAddressSpaceSink(); var sink = new DeferredAddressSpaceSink();
sink.SetSink(inner); 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(); inner.WriteValueCalled.ShouldBeTrue();
} }
@@ -63,6 +63,25 @@ public class DeferredAddressSpaceSinkTests
inner.RebuildCalled.ShouldBeTrue(); 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 ---------- // ---------- ISurgicalAddressSpaceSink forwarding ----------
[Fact] [Fact]
@@ -73,7 +92,7 @@ public class DeferredAddressSpaceSinkTests
sink.SetSink(new SpySink()); sink.SetSink(new SpySink());
sink.UpdateTagAttributes("ns=2;s=x", writable: true, historianTagname: null, 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] [Fact]
@@ -84,7 +103,7 @@ public class DeferredAddressSpaceSinkTests
sink.SetSink(surgical); sink.SetSink(surgical);
var result = sink.UpdateTagAttributes("ns=2;s=x", writable: true, historianTagname: null, 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(); result.ShouldBeTrue();
surgical.UpdateCalled.ShouldBeTrue(); surgical.UpdateCalled.ShouldBeTrue();
@@ -97,7 +116,7 @@ public class DeferredAddressSpaceSinkTests
var sink = new DeferredAddressSpaceSink(); var sink = new DeferredAddressSpaceSink();
sink.SetSink(new SpySink()); sink.SetSink(new SpySink());
sink.UpdateFolderDisplayName("area-1", "Plant South").ShouldBeFalse(); sink.UpdateFolderDisplayName("area-1", "Plant South", AddressSpaceRealm.Uns).ShouldBeFalse();
} }
[Fact] [Fact]
@@ -108,7 +127,7 @@ public class DeferredAddressSpaceSinkTests
var sink = new DeferredAddressSpaceSink(); var sink = new DeferredAddressSpaceSink();
sink.SetSink(surgical); sink.SetSink(surgical);
var result = sink.UpdateFolderDisplayName("area-1", "Plant South"); var result = sink.UpdateFolderDisplayName("area-1", "Plant South", AddressSpaceRealm.Uns);
result.ShouldBeTrue(); result.ShouldBeTrue();
surgical.FolderRenameCalled.ShouldBeTrue(); surgical.FolderRenameCalled.ShouldBeTrue();
@@ -122,9 +141,9 @@ public class DeferredAddressSpaceSinkTests
var sink = new DeferredAddressSpaceSink(); var sink = new DeferredAddressSpaceSink();
sink.SetSink(new SpySink()); sink.SetSink(new SpySink());
sink.RemoveVariableNode("v-1").ShouldBeFalse(); sink.RemoveVariableNode("v-1", AddressSpaceRealm.Uns).ShouldBeFalse();
sink.RemoveAlarmConditionNode("a-1").ShouldBeFalse(); sink.RemoveAlarmConditionNode("a-1", AddressSpaceRealm.Uns).ShouldBeFalse();
sink.RemoveEquipmentSubtree("eq-1").ShouldBeFalse(); sink.RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeFalse();
} }
[Fact] [Fact]
@@ -134,9 +153,9 @@ public class DeferredAddressSpaceSinkTests
var sink = new DeferredAddressSpaceSink(); var sink = new DeferredAddressSpaceSink();
sink.SetSink(surgical); sink.SetSink(surgical);
sink.RemoveVariableNode("v-1").ShouldBeTrue(); sink.RemoveVariableNode("v-1", AddressSpaceRealm.Uns).ShouldBeTrue();
sink.RemoveAlarmConditionNode("a-1").ShouldBeTrue(); sink.RemoveAlarmConditionNode("a-1", AddressSpaceRealm.Uns).ShouldBeTrue();
sink.RemoveEquipmentSubtree("eq-1").ShouldBeTrue(); sink.RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeTrue();
surgical.RemoveVariableCalled.ShouldBeTrue(); surgical.RemoveVariableCalled.ShouldBeTrue();
surgical.RemoveAlarmCalled.ShouldBeTrue(); surgical.RemoveAlarmCalled.ShouldBeTrue();
@@ -147,9 +166,9 @@ public class DeferredAddressSpaceSinkTests
public void Before_SetSink_remove_members_return_false() public void Before_SetSink_remove_members_return_false()
{ {
var sink = new DeferredAddressSpaceSink(); var sink = new DeferredAddressSpaceSink();
sink.RemoveVariableNode("v-1").ShouldBeFalse(); sink.RemoveVariableNode("v-1", AddressSpaceRealm.Uns).ShouldBeFalse();
sink.RemoveAlarmConditionNode("a-1").ShouldBeFalse(); sink.RemoveAlarmConditionNode("a-1", AddressSpaceRealm.Uns).ShouldBeFalse();
sink.RemoveEquipmentSubtree("eq-1").ShouldBeFalse(); sink.RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeFalse();
} }
// ---------- SetSink(null) reverts to null sink ---------- // ---------- SetSink(null) reverts to null sink ----------
@@ -163,7 +182,7 @@ public class DeferredAddressSpaceSinkTests
sink.SetSink(null); // revert sink.SetSink(null); // revert
sink.UpdateTagAttributes("ns=2;s=x", writable: false, historianTagname: null, 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] [Fact]
@@ -174,7 +193,7 @@ public class DeferredAddressSpaceSinkTests
sink.SetSink(inner); sink.SetSink(inner);
sink.SetSink(null); // revert 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"); 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 WriteValueCalled { get; private set; }
public bool RebuildCalled { 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; => WriteValueCalled = true;
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { } 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, bool isNative = false) { } 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) { } 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, 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) { }
public void RebuildAddressSpace() => RebuildCalled = true; 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 private sealed class SpySurgicalSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink
@@ -202,21 +225,23 @@ public class DeferredAddressSpaceSinkTests
public bool UpdateCalled { get; private set; } public bool UpdateCalled { get; private set; }
public bool FolderRenameCalled { get; private set; } public bool FolderRenameCalled { 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) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { } 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, bool isNative = false) { } 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) { } 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, 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) { }
public void RebuildAddressSpace() { } 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; UpdateCalled = true;
return true; return true;
} }
public bool UpdateFolderDisplayName(string folderNodeId, string displayName) public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{ {
FolderRenameCalled = true; FolderRenameCalled = true;
return true; return true;
@@ -226,8 +251,8 @@ public class DeferredAddressSpaceSinkTests
public bool RemoveAlarmCalled { get; private set; } public bool RemoveAlarmCalled { get; private set; }
public bool RemoveSubtreeCalled { get; private set; } public bool RemoveSubtreeCalled { get; private set; }
public bool RemoveVariableNode(string variableNodeId) { RemoveVariableCalled = true; return true; } public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveVariableCalled = true; return true; }
public bool RemoveAlarmConditionNode(string alarmNodeId) { RemoveAlarmCalled = true; return true; } public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveAlarmCalled = true; return true; }
public bool RemoveEquipmentSubtree(string equipmentNodeId) { RemoveSubtreeCalled = 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] [Fact]
public void Every_IServiceLevelPublisher_method_reaches_the_inner_publisher() 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] [Fact]
public async Task NullGateway_returns_writes_unavailable() 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.Success.ShouldBeFalse();
outcome.Reason.ShouldBe("writes unavailable"); outcome.Reason.ShouldBe("writes unavailable");
} }
@@ -205,6 +205,71 @@ public sealed class DraftValidatorTests
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "UnsEffectiveNameCollision"); DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "UnsEffectiveNameCollision");
} }
/// <summary>The rename-induced case the authoring guard never sees: an authoring-clean draft where
/// a reference (no override, effective name = backing raw tag's current Name) ends up sharing an
/// effective name with a VirtualTag after a raw-tag rename. The deploy gate is the backstop.</summary>
[Fact]
public void Reference_effective_from_raw_name_collides_with_virtualtag_after_rename()
{
var draft = new DraftSnapshot
{
GenerationId = 1, ClusterId = "c",
Tags = [BuildTag(tagId: "tag-1", name: "speed")], // raw tag renamed to "speed"
UnsTagReferences =
[
// No override → effective name is the backing raw tag's Name ("speed").
BuildReference(refId: "ref-1", equipmentId: "eq-1", tagId: "tag-1", overrideName: null),
],
VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "speed")],
};
var errors = DraftValidator.Validate(draft);
errors.ShouldContain(e => e.Code == "UnsEffectiveNameCollision");
// Names both colliding sources + the equipment.
var msg = errors.First(e => e.Code == "UnsEffectiveNameCollision").Message;
msg.ShouldContain("ref-1");
msg.ShouldContain("vtag-eq-1-speed");
msg.ShouldContain("eq-1");
}
/// <summary>A reference's DisplayNameOverride (not its backing raw name) is the effective name and
/// must be unique against a VirtualTag in the same equipment.</summary>
[Fact]
public void Reference_override_collides_with_virtualtag()
{
var draft = new DraftSnapshot
{
GenerationId = 1, ClusterId = "c",
Tags = [BuildTag(tagId: "tag-1", name: "raw-name")], // backing raw name differs
UnsTagReferences =
[
BuildReference(refId: "ref-1", equipmentId: "eq-1", tagId: "tag-1", overrideName: "computed"),
],
VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "computed")],
};
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "UnsEffectiveNameCollision");
}
/// <summary>Effective-name comparison is ordinal: "Speed" (VirtualTag) and "speed" (reference's
/// backing raw name) do NOT collide — they are distinct OPC UA browse names.</summary>
[Fact]
public void Effective_name_collision_is_ordinal_case_sensitive()
{
var draft = new DraftSnapshot
{
GenerationId = 1, ClusterId = "c",
Tags = [BuildTag(tagId: "tag-1", name: "speed")],
UnsTagReferences =
[
BuildReference(refId: "ref-1", equipmentId: "eq-1", tagId: "tag-1", overrideName: null),
],
VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "Speed")],
};
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "UnsEffectiveNameCollision");
}
private static VirtualTag BuildVirtualTag(string equipmentId, string name, string suffix = "") => new() private static VirtualTag BuildVirtualTag(string equipmentId, string name, string suffix = "") => new()
{ {
VirtualTagId = $"vtag-{equipmentId}-{name}{suffix}", VirtualTagId = $"vtag-{equipmentId}-{name}{suffix}",
@@ -214,6 +279,130 @@ public sealed class DraftValidatorTests
ScriptId = "s-1", ScriptId = "s-1",
}; };
private static Tag BuildTag(string tagId, string name) => new()
{
TagId = tagId,
DeviceId = "dev-1",
Name = name,
DataType = "Float",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{}",
};
private static UnsTagReference BuildReference(string refId, string equipmentId, string tagId, string? overrideName = null) => new()
{
UnsTagReferenceId = refId,
EquipmentId = equipmentId,
TagId = tagId,
DisplayNameOverride = overrideName,
};
// ------------------------------------------------------------------------------------
// ValidateEquipReferenceResolution — v3 WP4: {{equip}}/<RefName> must resolve to a reference
// ------------------------------------------------------------------------------------
private static Script BuildScript(string id, string source) => new()
{
ScriptId = id, Name = id, SourceCode = source, SourceHash = $"h-{id}",
};
private static Tag BuildRawTag(string tagId, string name) => new()
{
TagId = tagId, DeviceId = "dev-1", Name = name, DataType = "Float",
AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
};
/// <summary>A VirtualTag script using <c>{{equip}}/Speed</c> with no reference "Speed" on the equipment is
/// a deploy error naming the equipment + the missing ref.</summary>
[Fact]
public void VirtualTag_equip_ref_unresolved_is_deploy_error()
{
var draft = new DraftSnapshot
{
GenerationId = 1, ClusterId = "c",
Scripts = [BuildScript("s-1", "return ctx.GetTag(\"{{equip}}/Speed\").Value;")],
VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "vt")],
};
var err = DraftValidator.Validate(draft).First(e => e.Code == "EquipReferenceUnresolved");
err.Message.ShouldContain("eq-1");
err.Message.ShouldContain("Speed");
}
/// <summary>The same script resolves cleanly when the equipment has a reference whose effective name is
/// "Speed" (backing raw tag's Name).</summary>
[Fact]
public void VirtualTag_equip_ref_resolved_is_clean()
{
var draft = new DraftSnapshot
{
GenerationId = 1, ClusterId = "c",
Scripts = [BuildScript("s-1", "return ctx.GetTag(\"{{equip}}/Speed\").Value;")],
VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "vt")],
Tags = [BuildRawTag("tag-1", "Speed")],
UnsTagReferences = [BuildReference("ref-1", "eq-1", "tag-1")],
};
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "EquipReferenceUnresolved");
}
/// <summary>A DisplayNameOverride is the effective name: <c>{{equip}}/MotorSpeed</c> resolves to a
/// reference overridden to "MotorSpeed" even though the backing raw tag's Name is "raw_speed".</summary>
[Fact]
public void VirtualTag_equip_ref_resolves_by_override_name()
{
var draft = new DraftSnapshot
{
GenerationId = 1, ClusterId = "c",
Scripts = [BuildScript("s-1", "return ctx.GetTag(\"{{equip}}/MotorSpeed\").Value;")],
VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "vt")],
Tags = [BuildRawTag("tag-1", "raw_speed")],
UnsTagReferences = [BuildReference("ref-1", "eq-1", "tag-1", overrideName: "MotorSpeed")],
};
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "EquipReferenceUnresolved");
}
/// <summary>A reference on a DIFFERENT equipment does not satisfy the token — resolution is per owning
/// equipment.</summary>
[Fact]
public void VirtualTag_equip_ref_does_not_resolve_across_equipment()
{
var draft = new DraftSnapshot
{
GenerationId = 1, ClusterId = "c",
Scripts = [BuildScript("s-1", "return ctx.GetTag(\"{{equip}}/Speed\").Value;")],
VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "vt")],
Tags = [BuildRawTag("tag-1", "Speed")],
UnsTagReferences = [BuildReference("ref-1", "eq-2", "tag-1")], // reference is on eq-2, not eq-1
};
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "EquipReferenceUnresolved");
}
/// <summary>Alarm message-template <c>{{equip}}/Temp</c> follows the same rule — an unresolved ref in the
/// template is a deploy error.</summary>
[Fact]
public void ScriptedAlarm_message_template_equip_ref_unresolved_is_deploy_error()
{
var draft = new DraftSnapshot
{
GenerationId = 1, ClusterId = "c",
Scripts = [BuildScript("s-1", "return true;")],
ScriptedAlarms =
[
new ScriptedAlarm
{
ScriptedAlarmId = "al-1", EquipmentId = "eq-1", Name = "overheat",
AlarmType = "LimitAlarm", MessageTemplate = "Too hot: {{equip}}/Temp",
PredicateScriptId = "s-1",
},
],
};
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "EquipReferenceUnresolved");
}
// ------------------------------------------------------------------------------------ // ------------------------------------------------------------------------------------
// Phase 6.3 task #148 part 2 — ValidateClusterTopology (unchanged in v3) // Phase 6.3 task #148 part 2 — ValidateClusterTopology (unchanged in v3)
// ------------------------------------------------------------------------------------ // ------------------------------------------------------------------------------------
@@ -1,5 +1,6 @@
using Shouldly; using Shouldly;
using Xunit; using Xunit;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests; 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")] [Trait("Category", "Unit")]
public sealed class GalaxyDriverBrowserTests 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 /// <summary>The DriverType key must match the AdminUI's persisted "GalaxyMxGateway" value
/// so the factory wire-up picks the right browser implementation.</summary> /// so the factory wire-up picks the right browser implementation.</summary>
@@ -2,35 +2,48 @@ using Microsoft.Extensions.Logging;
using Shouldly; using Shouldly;
using Xunit; using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
/// <summary> /// <summary>
/// Follow-up #2 — pins the three resolution forms supported by /// Follow-up #2 + G-2a — pins the five resolution forms supported by
/// <see cref="GalaxySecretRef.ResolveApiKey"/>: <c>env:NAME</c>, <c>file:PATH</c>, /// <see cref="GalaxySecretRef.ResolveApiKeyAsync"/>: <c>env:NAME</c>, <c>file:PATH</c>,
/// and the literal-string fallback. A future DPAPI arm slots in here without /// <c>dev:KEY</c>, <c>secret:NAME</c> (via the shared <see cref="ISecretResolver"/>), and
/// touching the call site. (The resolver was extracted from <c>GalaxyDriver</c> to /// the literal-string fallback. The <c>secret:</c> arm is fail-closed — an absent secret
/// the shared <c>GalaxySecretRef</c> in Driver.Galaxy.Contracts so the runtime /// throws rather than leaking the ref string as the key. (The resolver was extracted from
/// driver and the AdminUI browser share one copy.) /// <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> /// </summary>
public sealed class GalaxyDriverApiKeyResolverTests 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> /// <summary>Verifies that a literal string is returned unchanged.</summary>
[Fact] [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> /// <summary>Verifies that env: prefix resolves to an environment variable.</summary>
[Fact] [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"; const string name = "OTOPCUA_TEST_GALAXY_API_KEY";
Environment.SetEnvironmentVariable(name, "key-from-env"); Environment.SetEnvironmentVariable(name, "key-from-env");
try try
{ {
GalaxySecretRef.ResolveApiKey($"env:{name}").ShouldBe("key-from-env"); (await GalaxySecretRef.ResolveApiKeyAsync($"env:{name}", FakeResolver()))
.ShouldBe("key-from-env");
} }
finally finally
{ {
@@ -40,26 +53,27 @@ public sealed class GalaxyDriverApiKeyResolverTests
/// <summary>Verifies that unset environment variables throw with a descriptive message.</summary> /// <summary>Verifies that unset environment variables throw with a descriptive message.</summary>
[Fact] [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"; const string name = "OTOPCUA_TEST_GALAXY_API_KEY_UNSET";
Environment.SetEnvironmentVariable(name, null); Environment.SetEnvironmentVariable(name, null);
var ex = Should.Throw<InvalidOperationException>(() => var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
GalaxySecretRef.ResolveApiKey($"env:{name}")); GalaxySecretRef.ResolveApiKeyAsync($"env:{name}", FakeResolver()));
ex.Message.ShouldContain(name); ex.Message.ShouldContain(name);
ex.Message.ShouldContain("unset"); ex.Message.ShouldContain("unset");
} }
/// <summary>Verifies that file: prefix resolves to trimmed file contents.</summary> /// <summary>Verifies that file: prefix resolves to trimmed file contents.</summary>
[Fact] [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"); var path = Path.Combine(Path.GetTempPath(), $"galaxy-key-{Guid.NewGuid():N}.txt");
File.WriteAllText(path, " key-from-file \n"); File.WriteAllText(path, " key-from-file \n");
try try
{ {
GalaxySecretRef.ResolveApiKey($"file:{path}").ShouldBe("key-from-file"); (await GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver()))
.ShouldBe("key-from-file");
} }
finally finally
{ {
@@ -69,26 +83,60 @@ public sealed class GalaxyDriverApiKeyResolverTests
/// <summary>Verifies that file: prefix with missing path throws.</summary> /// <summary>Verifies that file: prefix with missing path throws.</summary>
[Fact] [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 path = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.txt");
var ex = Should.Throw<InvalidOperationException>(() => var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
GalaxySecretRef.ResolveApiKey($"file:{path}")); GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver()));
ex.Message.ShouldContain(path); ex.Message.ShouldContain(path);
ex.Message.ShouldContain("doesn't exist"); 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 ===== // ===== Driver.Galaxy-010 regression: literal arm warns + dev: prefix path =====
/// <summary>Verifies that literal strings emit a warning when a logger is supplied.</summary> /// <summary>Verifies that literal strings emit a warning when a logger is supplied.</summary>
[Fact] [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 // 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 // in the DriverConfig JSON. The resolver must surface a warning so an
// operator who committed one by accident sees it at startup. // operator who committed one by accident sees it at startup.
var logger = new CaptureLogger(); 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"); key.ShouldBe("plain-text-key");
logger.Entries.ShouldContain(e => logger.Entries.ShouldContain(e =>
@@ -97,13 +145,13 @@ public sealed class GalaxyDriverApiKeyResolverTests
/// <summary>Verifies that dev: prefix returns literal text without emitting warnings.</summary> /// <summary>Verifies that dev: prefix returns literal text without emitting warnings.</summary>
[Fact] [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 // An explicit dev: prefix signals the operator knowingly opted into a literal
// key (dev / parity rig). The resolver must accept it AND suppress the // key (dev / parity rig). The resolver must accept it AND suppress the
// warning so production logs aren't polluted on a deliberate dev choice. // warning so production logs aren't polluted on a deliberate dev choice.
var logger = new CaptureLogger(); 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"); key.ShouldBe("plain-text-key");
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning); 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> /// <summary>Verifies that env: prefix does not emit literal string warnings.</summary>
[Fact] [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"; const string name = "OTOPCUA_TEST_GALAXY_API_KEY_NOWARN";
Environment.SetEnvironmentVariable(name, "v"); Environment.SetEnvironmentVariable(name, "v");
try try
{ {
var logger = new CaptureLogger(); var logger = new CaptureLogger();
GalaxySecretRef.ResolveApiKey($"env:{name}", logger); await GalaxySecretRef.ResolveApiKeyAsync($"env:{name}", FakeResolver(), logger);
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning); logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
} }
finally 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> /// <summary>A test logger that captures log entries for verification.</summary>
private sealed class CaptureLogger : ILogger private sealed class CaptureLogger : ILogger
{ {
@@ -144,16 +200,27 @@ public sealed class GalaxyDriverApiKeyResolverTests
=> Entries.Add((logLevel, formatter(state, exception))); => 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> /// <summary>Verifies that file: prefix with empty file throws.</summary>
[Fact] [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"); var path = Path.Combine(Path.GetTempPath(), $"galaxy-key-empty-{Guid.NewGuid():N}.txt");
File.WriteAllText(path, " \n "); File.WriteAllText(path, " \n ");
try try
{ {
var ex = Should.Throw<InvalidOperationException>(() => var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
GalaxySecretRef.ResolveApiKey($"file:{path}")); GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver()));
ex.Message.ShouldContain("empty"); ex.Message.ShouldContain("empty");
} }
finally finally
@@ -143,7 +143,7 @@ public sealed class GalaxyDriverFactoryTests
public void Register_AddsFactoryToRegistry() public void Register_AddsFactoryToRegistry()
{ {
var registry = new DriverFactoryRegistry(); var registry = new DriverFactoryRegistry();
GalaxyDriverFactoryExtensions.Register(registry); GalaxyDriverFactoryExtensions.Register(registry, NullSecretResolver.Instance);
registry.RegisteredTypes.ShouldContain(GalaxyDriverFactoryExtensions.DriverTypeName); 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 // _dataReader and _subscriber are both null. The follow-up read path can't
// synthesise a Read without one, so it surfaces a NotSupportedException // synthesise a Read without one, so it surfaces a NotSupportedException
// pointing at the misuse rather than NullRef'ing inside the pump path. // 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>(() => var ex = await Should.ThrowAsync<NotSupportedException>(() =>
driver.ReadAsync(["x"], CancellationToken.None)); driver.ReadAsync(["x"], CancellationToken.None));
@@ -242,7 +242,7 @@ public sealed class GalaxyDriverSubscribeTests
[Fact] [Fact]
public async Task SubscribeAsync_NoSubscriber_Throws() 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>(() => var ex = await Should.ThrowAsync<NotSupportedException>(() =>
driver.SubscribeAsync(["x"], TimeSpan.FromSeconds(1), CancellationToken.None)); driver.SubscribeAsync(["x"], TimeSpan.FromSeconds(1), CancellationToken.None));
ex.Message.ShouldContain("GalaxyDriver.SubscribeAsync requires"); ex.Message.ShouldContain("GalaxyDriver.SubscribeAsync requires");
@@ -192,7 +192,7 @@ public sealed class GalaxyDriverWriteTests
[Fact] [Fact]
public async Task WriteAsync_NoWriter_Throws() 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>(() => var ex = await Should.ThrowAsync<NotSupportedException>(() =>
driver.WriteAsync([new WriteRequest("x", 1)], CancellationToken.None)); 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,
});
}
}
@@ -14,7 +14,7 @@ public sealed class CtxCompletionGuardTests
=> Task.FromResult<IReadOnlyList<string>>(new[] { "Line1.Speed" }); => Task.FromResult<IReadOnlyList<string>>(new[] { "Line1.Speed" });
public Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct) public Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct)
=> Task.FromResult<ScriptTagInfo?>(new ScriptTagInfo(path, "Virtual tag", "Double", null)); => Task.FromResult<ScriptTagInfo?>(new ScriptTagInfo(path, "Virtual tag", "Double", null));
public Task<IReadOnlyList<string>> GetEquipmentRelativeLeavesAsync(string? f, CancellationToken ct) public Task<IReadOnlyList<string>> GetEquipmentReferenceNamesAsync(string equipmentId, string? f, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<string>>(System.Array.Empty<string>()); => Task.FromResult<IReadOnlyList<string>>(System.Array.Empty<string>());
} }
@@ -5,63 +5,96 @@ using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
/// <summary> /// <summary>
/// Covers the two {{equip}}-aware editor niceties: hover on a path literal containing the /// v3 {{equip}}/&lt;RefName&gt; editor niceties: hover on a path literal containing the token shows an
/// <c>{{equip}}</c> token shows an "equipment-relative" note (not the "not a known configured /// "equipment-relative" note (not the "not a known configured tag path" warning); completion after
/// tag path" warning), and completion after <c>{{equip}}.</c> offers the attribute leaf names. /// <c>{{equip}}/</c> offers the owning equipment's reference effective names; and the diagnostic flags an
/// unresolved <c>{{equip}}/&lt;RefName&gt;</c> exactly as the deploy gate does (editor accepts ⇔ publish
/// accepts).
/// </summary> /// </summary>
public sealed class EquipTokenEditorTests public sealed class EquipTokenEditorTests
{ {
/// <summary>A fake catalog whose configured paths all share an object prefix, so the attribute /// <summary>A fake catalog whose equipment has two references — "Speed" and "Temp" — so those are the
/// leaf names ("Source", "Other") are what {{equip}}. completion should surface.</summary> /// resolvable {{equip}}/&lt;RefName&gt; names for the completion + diagnostic.</summary>
private sealed class FakeCatalog : IScriptTagCatalog private sealed class FakeCatalog : IScriptTagCatalog
{ {
private static readonly string[] Paths = { "Mixer_001.Source", "Mixer_001.Other" };
public Task<IReadOnlyList<string>> GetPathsAsync(string? filter, CancellationToken ct) public Task<IReadOnlyList<string>> GetPathsAsync(string? filter, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<string>>(Paths); => Task.FromResult<IReadOnlyList<string>>(new[] { "Cell1/Modbus/Dev1/Speed" });
public Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct) public Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct)
=> Task.FromResult<ScriptTagInfo?>(null); => Task.FromResult<ScriptTagInfo?>(null);
public Task<IReadOnlyList<string>> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct) public Task<IReadOnlyList<string>> GetEquipmentReferenceNamesAsync(string equipmentId, string? filter, CancellationToken ct)
{ {
IEnumerable<string> leaves = new[] { "Source", "Other" }; IEnumerable<string> names = new[] { "Speed", "Temp" };
if (!string.IsNullOrWhiteSpace(filter)) if (!string.IsNullOrWhiteSpace(filter))
leaves = leaves.Where(n => n.StartsWith(filter, StringComparison.OrdinalIgnoreCase)); names = names.Where(n => n.StartsWith(filter, StringComparison.OrdinalIgnoreCase));
return Task.FromResult<IReadOnlyList<string>>(leaves.ToList()); return Task.FromResult<IReadOnlyList<string>>(names.ToList());
} }
} }
private static readonly ScriptAnalysisService Svc = new(new FakeCatalog()); private static readonly ScriptAnalysisService Svc = new(new FakeCatalog());
private static async Task<IReadOnlyList<CompletionItem>> Items(string code, int line, int col) private static async Task<IReadOnlyList<CompletionItem>> Items(string code, int line, int col, string? equipmentId)
=> (await Svc.CompleteAsync(new CompletionsRequest(code, line, col))).Items; => (await Svc.CompleteAsync(new CompletionsRequest(code, line, col, equipmentId))).Items;
[Fact] public async Task Completion_after_equip_dot_offers_attribute_leaves() [Fact] public async Task Completion_after_equip_slash_offers_reference_names()
{ {
// caret right after the dot of "{{equip}}." — column 30 sits between the trailing dot and // caret right after the slash of "{{equip}}/" — column 30 sits between the trailing slash and the
// the closing quote of ctx.GetTag("{{equip}}."). // closing quote of ctx.GetTag("{{equip}}/").
var items = await Items("""return ctx.GetTag("{{equip}}.").Value;""", 1, 30); var items = await Items("""return ctx.GetTag("{{equip}}/").Value;""", 1, 30, "EQ-1");
items.Select(i => i.Label).ShouldContain("{{equip}}.Source"); items.Select(i => i.Label).ShouldContain("{{equip}}/Speed");
items.Select(i => i.Label).ShouldContain("{{equip}}/Temp");
items.ShouldAllBe(i => i.Detail == "tag path"); items.ShouldAllBe(i => i.Detail == "tag path");
} }
[Fact] public async Task Completion_after_equip_dot_inserts_the_full_token_qualified_leaf() [Fact] public async Task Completion_after_equip_slash_inserts_the_full_token_qualified_ref()
{ {
var items = await Items("""return ctx.GetTag("{{equip}}.").Value;""", 1, 30); var items = await Items("""return ctx.GetTag("{{equip}}/").Value;""", 1, 30, "EQ-1");
var source = items.FirstOrDefault(i => i.Label == "{{equip}}.Source"); var speed = items.FirstOrDefault(i => i.Label == "{{equip}}/Speed");
source.ShouldNotBeNull(); speed.ShouldNotBeNull();
source!.InsertText.ShouldBe("{{equip}}.Source"); speed!.InsertText.ShouldBe("{{equip}}/Speed");
}
[Fact] public async Task Completion_after_equip_slash_is_inert_without_equipment_context()
{
// Shared ScriptEdit page (no EquipmentId) can't resolve references → no equip completions.
var items = await Items("""return ctx.GetTag("{{equip}}/").Value;""", 1, 30, null);
items.ShouldBeEmpty();
} }
[Fact] public async Task Hover_on_equip_token_literal_notes_equipment_relative() [Fact] public async Task Hover_on_equip_token_literal_notes_equipment_relative()
{ {
// caret inside ctx.GetTag("{{equip}}.Source") // caret inside ctx.GetTag("{{equip}}/Speed")
var md = (await Svc.Hover(new HoverRequest("""return ctx.GetTag("{{equip}}.Source").Value;""", 1, 24))).Markdown; var md = (await Svc.Hover(new HoverRequest("""return ctx.GetTag("{{equip}}/Speed").Value;""", 1, 24))).Markdown;
md.ShouldNotBeNull(); md.ShouldNotBeNull();
md!.ShouldContain("Equipment-relative"); md!.ShouldContain("Equipment-relative");
// the {{equip}} token must render literally (non-interpolated markdown segment) // the {{equip}} token must render literally (non-interpolated markdown segment)
md.ShouldContain("{{equip}}"); md.ShouldContain("{{equip}}");
md.ShouldNotContain("Not a known"); md.ShouldNotContain("Not a known");
} }
[Fact] public async Task Diagnostic_flags_unresolved_equip_reference()
{
// "Missing" is not one of the equipment's references (Speed/Temp) → a diagnostic, matching the
// deploy gate. Uses the equipment context via the request's EquipmentId.
var resp = await Svc.DiagnoseAsync(
new DiagnoseRequest("""return ctx.GetTag("{{equip}}/Missing").Value;""", "EQ-1"));
resp.Markers.ShouldContain(m => m.Code == "OTSCRIPT_EQUIPREF" && m.Message.Contains("Missing"));
}
[Fact] public async Task Diagnostic_clean_for_resolved_equip_reference()
{
var resp = await Svc.DiagnoseAsync(
new DiagnoseRequest("""return ctx.GetTag("{{equip}}/Speed").Value;""", "EQ-1"));
resp.Markers.ShouldNotContain(m => m.Code == "OTSCRIPT_EQUIPREF");
}
[Fact] public async Task Diagnostic_equipref_inert_without_equipment_context()
{
// No EquipmentId (shared ScriptEdit page) → the equip-ref check is inert (base markers only),
// matching the deploy gate which only resolves per owning equipment.
var resp = await Svc.DiagnoseAsync(
new DiagnoseRequest("""return ctx.GetTag("{{equip}}/Missing").Value;"""));
resp.Markers.ShouldNotContain(m => m.Code == "OTSCRIPT_EQUIPREF");
}
} }
@@ -16,7 +16,7 @@ public sealed class HoverSignatureTests
=> Task.FromResult<IReadOnlyList<string>>(System.Array.Empty<string>()); => Task.FromResult<IReadOnlyList<string>>(System.Array.Empty<string>());
public Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct) public Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct)
=> Task.FromResult(path == "Line1.Speed" ? new ScriptTagInfo("Line1.Speed", "Tag", "Double", "MAIN-modbus") : null); => Task.FromResult(path == "Line1.Speed" ? new ScriptTagInfo("Line1.Speed", "Tag", "Double", "MAIN-modbus") : null);
public Task<IReadOnlyList<string>> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct) public Task<IReadOnlyList<string>> GetEquipmentReferenceNamesAsync(string equipmentId, string? filter, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<string>>(new[] { "Speed" }); => Task.FromResult<IReadOnlyList<string>>(new[] { "Speed" });
} }
@@ -285,82 +285,66 @@ public sealed class ScriptTagCatalogTests
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// GetEquipmentRelativeLeavesAsync — DB-backed tests // GetEquipmentReferenceNamesAsync — DB-backed tests (v3 reference-relative {{equip}}/<RefName>)
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Seeded paths and their leaf derivation: // Effective name = UnsTagReference.DisplayNameOverride else the backing raw tag's Name.
// TAG-EQ → "Motor.Speed" → leaf "Speed"
// TAG-SP → "DelmiaReceiver_001.DownloadPath" → leaf "DownloadPath"
// VTAG-1 → "Computed" → NO dot → excluded
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
/// <summary>A null filter returns one leaf per dot-bearing path; the no-dot virtual-tag path is excluded.</summary> /// <summary>Seeds equipment EQ-1 with two references: one to TAG-EQ (Name "Speed", no override) and one
[Fact] /// to TAG-SP (Name "DownloadPath") under a display override "Path".</summary>
public async Task GetEquipmentRelativeLeaves_no_filter_returns_dot_bearing_leaves_only() private static void SeedReferences(DbContextOptions<OtOpcUaConfigDbContext> opts)
{ {
var (catalog, opts) = Fresh(); using var db = new OtOpcUaConfigDbContext(opts);
Seed(opts); db.UnsTagReferences.Add(new UnsTagReference
var leaves = await catalog.GetEquipmentRelativeLeavesAsync(null, default);
// Leaves derived from the two dot-bearing paths in Seed().
leaves.ShouldContain("Speed"); // from "Motor.Speed"
leaves.ShouldContain("DownloadPath"); // from "DelmiaReceiver_001.DownloadPath"
// Virtual tag "Computed" has no dot — must be excluded.
leaves.ShouldNotContain("Computed");
}
/// <summary>A prefix filter narrows to leaves that start with the given string (e.g. "Sp" → "Speed", not "DownloadPath").</summary>
[Fact]
public async Task GetEquipmentRelativeLeaves_prefix_filter_narrows_to_matching_leaves()
{
var (catalog, opts) = Fresh();
Seed(opts);
var leaves = await catalog.GetEquipmentRelativeLeavesAsync("Sp", default);
leaves.ShouldContain("Speed");
leaves.ShouldNotContain("DownloadPath");
leaves.ShouldAllBe(l => l.StartsWith("Sp", StringComparison.OrdinalIgnoreCase));
}
/// <summary>The prefix filter is case-insensitive: "sp" (lowercase) still matches the leaf "Speed".</summary>
[Fact]
public async Task GetEquipmentRelativeLeaves_prefix_filter_is_case_insensitive()
{
var (catalog, opts) = Fresh();
Seed(opts);
var leaves = await catalog.GetEquipmentRelativeLeavesAsync("sp", default);
leaves.ShouldContain("Speed");
}
/// <summary>Two paths sharing the same leaf after different object prefixes produce one distinct entry.</summary>
[Fact]
public async Task GetEquipmentRelativeLeaves_duplicate_leaves_are_deduplicated()
{
var (catalog, opts) = Fresh();
Seed(opts);
// Add a second equipment tag whose FullName produces the same leaf "Speed" as TAG-EQ.
using (var db = new OtOpcUaConfigDbContext(opts))
{ {
db.Tags.Add(new Tag UnsTagReferenceId = "REF-1", EquipmentId = "EQ-1", TagId = "TAG-EQ", DisplayNameOverride = null,
{ });
TagId = "TAG-EQ2", db.UnsTagReferences.Add(new UnsTagReference
DeviceId = "DEV-1", {
Name = "Speed2", UnsTagReferenceId = "REF-2", EquipmentId = "EQ-1", TagId = "TAG-SP", DisplayNameOverride = "Path",
DataType = "Float", });
AccessLevel = TagAccessLevel.Read, db.SaveChanges();
TagConfig = "{\"FullName\":\"Conveyor.Speed\"}", }
});
db.SaveChanges();
}
var leaves = await catalog.GetEquipmentRelativeLeavesAsync(null, default); /// <summary>Returns the equipment's reference effective names — backing tag Name when no override, the
/// override otherwise.</summary>
[Fact]
public async Task GetEquipmentReferenceNames_returns_effective_names()
{
var (catalog, opts) = Fresh();
Seed(opts);
SeedReferences(opts);
// "Speed" must appear exactly once despite being produced by two different paths. var names = await catalog.GetEquipmentReferenceNamesAsync("EQ-1", null, default);
leaves.Count(l => string.Equals(l, "Speed", StringComparison.Ordinal)).ShouldBe(1);
names.ShouldContain("Speed"); // TAG-EQ Name, no override
names.ShouldContain("Path"); // TAG-SP under the "Path" override
names.ShouldNotContain("DownloadPath"); // the raw Name is superseded by the override
}
/// <summary>A prefix filter narrows (case-insensitively) to reference names that start with the prefix.</summary>
[Fact]
public async Task GetEquipmentReferenceNames_prefix_filter_is_case_insensitive()
{
var (catalog, opts) = Fresh();
Seed(opts);
SeedReferences(opts);
var names = await catalog.GetEquipmentReferenceNamesAsync("EQ-1", "sp", default);
names.ShouldContain("Speed");
names.ShouldNotContain("Path");
}
/// <summary>An unknown equipment (or one with no references) yields an empty set; a blank id yields empty.</summary>
[Fact]
public async Task GetEquipmentReferenceNames_empty_for_unknown_or_blank_equipment()
{
var (catalog, opts) = Fresh();
Seed(opts);
SeedReferences(opts);
(await catalog.GetEquipmentReferenceNamesAsync("EQ-UNKNOWN", null, default)).ShouldBeEmpty();
(await catalog.GetEquipmentReferenceNamesAsync("", null, default)).ShouldBeEmpty();
} }
} }
@@ -14,8 +14,7 @@ public sealed class TagPathCompletionTests
public Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct) public Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct)
=> Task.FromResult<ScriptTagInfo?>(null); => Task.FromResult<ScriptTagInfo?>(null);
public Task<IReadOnlyList<string>> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct) public Task<IReadOnlyList<string>> GetEquipmentReferenceNamesAsync(string equipmentId, string? filter, CancellationToken ct)
// canned leaves derived from the canned paths above (substring after the first dot)
=> Task.FromResult<IReadOnlyList<string>>(new[] { "Speed", "Temp" }); => Task.FromResult<IReadOnlyList<string>>(new[] { "Speed", "Temp" });
} }
@@ -0,0 +1,203 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Unit tests for <see cref="EffectiveNameGuard"/> — the authoring-time UNS effective-name
/// uniqueness guard. Backed by an InMemory EF database seeded with one equipment carrying a
/// reference (override + backing raw tag), a VirtualTag, and a ScriptedAlarm.
/// </summary>
public sealed class EffectiveNameGuardTests
{
private const string EquipmentId = "EQ-000000000001";
// Reference REF-1 → raw tag TAG-1 (Name "speed"), NO override → effective "speed".
// Reference REF-2 → raw tag TAG-2 (Name "raw-temp"), override "temperature" → effective "temperature".
// VirtualTag VT-1 (Name "computed"). ScriptedAlarm AL-1 (Name "highpress").
private const string RefNoOverrideId = "REF-1";
private const string RefWithOverrideId = "REF-2";
private const string VirtualTagId = "VT-1";
private const string ScriptedAlarmId = "AL-1";
private static IDbContextFactory<OtOpcUaConfigDbContext> SeedFactory()
{
var name = $"engname-{Guid.NewGuid():N}";
var factory = new NamedFactory(name);
using var db = factory.CreateDbContext();
db.Tags.Add(new Tag
{
TagId = "TAG-1",
DeviceId = "DEV-1",
Name = "speed",
DataType = "Float",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{}",
});
db.Tags.Add(new Tag
{
TagId = "TAG-2",
DeviceId = "DEV-1",
Name = "raw-temp",
DataType = "Float",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{}",
});
db.UnsTagReferences.Add(new UnsTagReference
{
UnsTagReferenceId = RefNoOverrideId,
EquipmentId = EquipmentId,
TagId = "TAG-1",
DisplayNameOverride = null, // effective = raw tag Name "speed"
});
db.UnsTagReferences.Add(new UnsTagReference
{
UnsTagReferenceId = RefWithOverrideId,
EquipmentId = EquipmentId,
TagId = "TAG-2",
DisplayNameOverride = "temperature", // effective = "temperature"
});
db.VirtualTags.Add(new VirtualTag
{
VirtualTagId = VirtualTagId,
EquipmentId = EquipmentId,
Name = "computed",
DataType = "Float",
ScriptId = "SCR-1",
});
db.ScriptedAlarms.Add(new ScriptedAlarm
{
ScriptedAlarmId = ScriptedAlarmId,
EquipmentId = EquipmentId,
Name = "highpress",
AlarmType = "AlarmCondition",
MessageTemplate = "{TagPath} high",
PredicateScriptId = "SCR-2",
});
db.SaveChanges();
return factory;
}
private static EffectiveNameGuard Guard(IDbContextFactory<OtOpcUaConfigDbContext> factory) => new(factory);
[Fact]
public async Task Free_name_returns_null()
{
var guard = Guard(SeedFactory());
var result = await guard.CheckAsync(
EquipmentId, "brand-new-name", EffectiveNameSourceKind.VirtualTag, excludeSourceId: null);
result.ShouldBeNull();
}
[Fact]
public async Task Reference_vs_new_virtualtag_clash_returns_error_naming_the_reference()
{
var guard = Guard(SeedFactory());
// A new VirtualTag proposing "speed" collides with reference REF-1's effective name.
var result = await guard.CheckAsync(
EquipmentId, "speed", EffectiveNameSourceKind.VirtualTag, excludeSourceId: null);
result.ShouldNotBeNull();
result.ShouldBe($"effective name 'speed' already used by reference '{RefNoOverrideId}' in equipment '{EquipmentId}'");
}
[Fact]
public async Task Override_effective_name_vs_new_reference_clash_returns_error()
{
var guard = Guard(SeedFactory());
// A new reference proposing "temperature" collides with REF-2's OVERRIDE effective name.
var result = await guard.CheckAsync(
EquipmentId, "temperature", EffectiveNameSourceKind.Reference, excludeSourceId: null);
result.ShouldNotBeNull();
result.ShouldBe($"effective name 'temperature' already used by reference '{RefWithOverrideId}' in equipment '{EquipmentId}'");
}
[Fact]
public async Task New_virtualtag_vs_scripted_alarm_clash_returns_error_naming_the_alarm()
{
var guard = Guard(SeedFactory());
var result = await guard.CheckAsync(
EquipmentId, "highpress", EffectiveNameSourceKind.VirtualTag, excludeSourceId: null);
result.ShouldNotBeNull();
result.ShouldBe($"effective name 'highpress' already used by ScriptedAlarm '{ScriptedAlarmId}' in equipment '{EquipmentId}'");
}
[Fact]
public async Task Self_exclude_on_override_change_returns_null()
{
var guard = Guard(SeedFactory());
// Editing REF-2 to change its override to "temperature" (its own current effective name)
// must NOT collide with itself.
var result = await guard.CheckAsync(
EquipmentId, "temperature", EffectiveNameSourceKind.Reference, excludeSourceId: RefWithOverrideId);
result.ShouldBeNull();
}
[Fact]
public async Task Self_exclude_only_applies_to_same_kind()
{
var guard = Guard(SeedFactory());
// Excluding a VirtualTag whose id happens to equal a reference id must NOT excuse the
// reference collision — the self-row is identified by (kind, id) together.
var result = await guard.CheckAsync(
EquipmentId, "speed", EffectiveNameSourceKind.VirtualTag, excludeSourceId: RefNoOverrideId);
result.ShouldNotBeNull();
result.ShouldBe($"effective name 'speed' already used by reference '{RefNoOverrideId}' in equipment '{EquipmentId}'");
}
[Fact]
public async Task Ordinal_case_sensitivity_Speed_does_not_collide_with_speed()
{
var guard = Guard(SeedFactory());
// "Speed" (capital S) must NOT collide with the existing "speed" (ordinal comparison).
var result = await guard.CheckAsync(
EquipmentId, "Speed", EffectiveNameSourceKind.VirtualTag, excludeSourceId: null);
result.ShouldBeNull();
}
[Fact]
public async Task Collision_is_scoped_to_the_equipment()
{
var guard = Guard(SeedFactory());
// Same name, different equipment → no collision (per-equipment NodeId space).
var result = await guard.CheckAsync(
"EQ-000000000002", "speed", EffectiveNameSourceKind.VirtualTag, excludeSourceId: null);
result.ShouldBeNull();
}
private sealed class NamedFactory(string name) : IDbContextFactory<OtOpcUaConfigDbContext>
{
public OtOpcUaConfigDbContext CreateDbContext() =>
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase(name)
.Options);
public Task<OtOpcUaConfigDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default) =>
Task.FromResult(CreateDbContext());
}
}
@@ -0,0 +1,231 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Covers the B3-WP3 rename-warning extension of <see cref="RawTreeService"/>: the three warning kinds a
/// tag/ancestor rename can raise — (a) historized WITHOUT a <c>historianTagname</c> override (history
/// forks), (b) UNS-referenced (names the equipment), and (c) matched by a substring scan of a
/// <c>Script</c> body for the affected tag's OLD RawPath.
/// <para>
/// Seeds a bespoke InMemory slice (independent of the shared <c>RawTreeTestDb</c>) so each device holds
/// exactly one tag with a controlled property mix. The EF InMemory provider does not enforce
/// <c>RowVersion</c> tokens, so renames commit against the seeded row versions.
/// </para>
/// </summary>
[Trait("Category", "Unit")]
public sealed class RawTreeServiceRenameWarningTests
{
private const string ClusterId = "MAIN";
private const string FolderId = "RF-plant";
private const string DriverId = "DRV-modbus";
// Dev-all → the "everything" tag: historized (no override) + referenced + named in a script literal.
private const string DevAllId = "DEV-all";
private const string FlowTagId = "TAG-flow";
private const string FlowRawPath = "Plant/modbus-1/Dev-all/flow";
// Dev-pinned → a historized tag WITH a historianTagname override (pinned; must NOT warn).
private const string DevPinnedId = "DEV-pinned";
private const string TempTagId = "TAG-temp";
// Dev-plain → a plain tag, unreferenced + absent from every script (must warn nothing).
private const string DevPlainId = "DEV-plain";
private const string IdleTagId = "TAG-idle";
private const string EquipmentId = "EQ-000000000001";
private const string EquipmentName = "packer-1";
private const string ScriptName = "AreaCalc";
private static (RawTreeService Service, string DbName) Seeded()
{
var name = $"rawwarn-{Guid.NewGuid():N}";
using var db = CreateNamed(name);
Seed(db);
return (new RawTreeService(new NamedFactory(name)), name);
}
private static OtOpcUaConfigDbContext CreateNamed(string name) =>
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseInMemoryDatabase(name).Options);
private static byte[] DeviceRowVersion(string dbName, string deviceId)
{
using var db = CreateNamed(dbName);
return db.Devices.Single(d => d.DeviceId == deviceId).RowVersion;
}
private static byte[] DriverRowVersion(string dbName, string driverId)
{
using var db = CreateNamed(dbName);
return db.DriverInstances.Single(d => d.DriverInstanceId == driverId).RowVersion;
}
// ------------------------------------------------------------------------------------- Scenarios
[Fact]
public async Task Rename_of_ancestor_of_a_historized_referenced_scripted_tag_raises_all_three_warnings()
{
var (service, dbName) = Seeded();
var rv = DeviceRowVersion(dbName, DevAllId);
var result = await service.RenameDeviceAsync(DevAllId, "Dev-renamed", rv);
result.Ok.ShouldBeTrue();
result.Warnings.ShouldContain(w => w.Contains("historian")); // (a)
result.Warnings.ShouldContain(w => w.Contains(EquipmentName)); // (b)
result.Warnings.ShouldContain(w => w.Contains("script") && w.Contains(ScriptName)); // (c)
result.Warnings.Count.ShouldBe(3);
}
[Fact]
public async Task Rename_of_a_pinned_historized_tag_raises_no_historized_warning()
{
var (service, dbName) = Seeded();
var rv = DeviceRowVersion(dbName, DevPinnedId);
var result = await service.RenameDeviceAsync(DevPinnedId, "Dev-pinned2", rv);
result.Ok.ShouldBeTrue();
// A historianTagname override pins the tagname, so its history does NOT fork — no (a) warning; and
// the pinned tag is neither referenced nor scripted, so nothing else fires either.
result.Warnings.ShouldNotContain(w => w.Contains("historian"));
result.Warnings.ShouldBeEmpty();
}
[Fact]
public async Task Rename_of_an_unrelated_tags_ancestor_raises_no_warnings()
{
var (service, dbName) = Seeded();
var rv = DeviceRowVersion(dbName, DevPlainId);
var result = await service.RenameDeviceAsync(DevPlainId, "Dev-plain2", rv);
result.Ok.ShouldBeTrue();
result.Warnings.ShouldBeEmpty();
}
[Fact]
public async Task Script_scan_matches_when_an_ancestor_is_renamed_and_the_old_path_is_in_a_script_body()
{
var (service, dbName) = Seeded();
var rv = DriverRowVersion(dbName, DriverId);
// Renaming the DRIVER (an ancestor of the scripted tag) still computes the tag's unchanged OLD
// RawPath (rename not yet saved) and finds it as a substring of the script body.
var result = await service.RenameDriverAsync(DriverId, "modbus-renamed", rv);
result.Ok.ShouldBeTrue();
result.Warnings.ShouldContain(w => w.Contains("script") && w.Contains(ScriptName));
}
// ------------------------------------------------------------------------------------------ Seed
private static void Seed(OtOpcUaConfigDbContext db)
{
db.ServerClusters.Add(new ServerCluster
{
ClusterId = ClusterId,
Name = "Main",
Enterprise = "zb",
Site = "warsaw-west",
RedundancyMode = RedundancyMode.None,
CreatedBy = "test",
});
db.RawFolders.Add(new RawFolder
{
RawFolderId = FolderId,
ClusterId = ClusterId,
ParentRawFolderId = null,
Name = "Plant",
});
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = DriverId,
ClusterId = ClusterId,
RawFolderId = FolderId,
Name = "modbus-1",
DriverType = "Modbus",
DriverConfig = "{}",
});
db.Devices.Add(new Device { DeviceId = DevAllId, DriverInstanceId = DriverId, Name = "Dev-all", DeviceConfig = "{}" });
db.Devices.Add(new Device { DeviceId = DevPinnedId, DriverInstanceId = DriverId, Name = "Dev-pinned", DeviceConfig = "{}" });
db.Devices.Add(new Device { DeviceId = DevPlainId, DriverInstanceId = DriverId, Name = "Dev-plain", DeviceConfig = "{}" });
// Historized, no historianTagname override → its default tagname is FlowRawPath, which moves.
db.Tags.Add(new Tag
{
TagId = FlowTagId,
DeviceId = DevAllId,
TagGroupId = null,
Name = "flow",
DataType = "Float",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{\"isHistorized\":true}",
});
// Historized WITH a historianTagname override → pinned, must not fork/warn.
db.Tags.Add(new Tag
{
TagId = TempTagId,
DeviceId = DevPinnedId,
TagGroupId = null,
Name = "temp",
DataType = "Float",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{\"isHistorized\":true,\"historianTagname\":\"Plant.Pinned.Temp\"}",
});
// Plain, unreferenced, absent from scripts.
db.Tags.Add(new Tag
{
TagId = IdleTagId,
DeviceId = DevPlainId,
TagGroupId = null,
Name = "idle",
DataType = "Boolean",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{}",
});
db.Equipment.Add(new Equipment
{
EquipmentId = EquipmentId,
EquipmentUuid = Guid.NewGuid(),
UnsLineId = "LINE-1",
Name = EquipmentName,
MachineCode = "packer_001",
});
db.UnsTagReferences.Add(new UnsTagReference
{
UnsTagReferenceId = "REF-flow",
EquipmentId = EquipmentId,
TagId = FlowTagId,
});
// A script whose body names the flow tag by its absolute RawPath literal.
db.Scripts.Add(new Script
{
ScriptId = "SC-areacalc",
Name = ScriptName,
SourceCode = $"var v = ctx.GetTag(\"{FlowRawPath}\"); return v * 2;",
SourceHash = "hash-areacalc",
});
db.SaveChanges();
}
private sealed class NamedFactory(string name) : IDbContextFactory<OtOpcUaConfigDbContext>
{
public OtOpcUaConfigDbContext CreateDbContext() =>
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseInMemoryDatabase(name).Options);
public Task<OtOpcUaConfigDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default) =>
Task.FromResult(CreateDbContext());
}
}

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