Compare commits

...

23 Commits

Author SHA1 Message Date
4fe96fca9b Merge pull request 'AbCip IAlarmSource via ALMD projection (#177, feature-flagged)' (#159) from abcip-alarm-source into v2 2026-04-20 04:26:39 -04:00
Joseph Doherty
4e80db4844 AbCip IAlarmSource via ALMD projection (#177) — feature-flagged OFF by default; when enabled, polls declared ALMD UDT member fields + raises OnAlarmEvent on 0→1 + 1→0 transitions. Closes task #177. The AB CIP driver now implements IAlarmSource so the generic-driver alarm dispatch path (PR 14's sinks + the Server.Security.AuthorizationGate AlarmSubscribe/AlarmAck invoker wrapping) can treat AB-backed alarms uniformly with Galaxy + OpcUaClient + FOCAS. Projection is ALMD-only in this pass: the Logix ALMD (digital alarm) instruction's UDT shape is well-understood (InFaulted + Acked + Severity + In + Cfg_ProgTime at stable member names) so the polled-read + state-diff pattern fits without concessions. ALMA (analog alarm) deferred to a follow-up because its HHLimit/HLimit/LLimit/LLLimit threshold + In value semantics deserve their own design pass — raising on threshold-crossing is not the same shape as raising on InFaulted-edge. AbCipDriverOptions gains two knobs: EnableAlarmProjection (default false) + AlarmPollInterval (default 1s). Explicit opt-in because projection semantics don't exactly mirror Rockwell FT Alarm & Events; shops running FT Live should leave this off + take alarms through the native A&E route. AbCipAlarmProjection is the state machine: per-subscription background loop polls the source-node set via the driver's public ReadAsync — which gains the #194 whole-UDT optimization for free when ALMDs are declared with their standard member set, so one poll tick reads (N alarms × 2 members) = N libplctag round-trips rather than 2N. Per-tick state diff: compare InFaulted + Severity against last-seen, fire raise (0→1) / clear (1→0) with AlarmSeverity bucketed via the 1-1000 Logix severity scale (≤250 Low, ≤500 Medium, ≤750 High, rest Critical — matches OpcUaClient's MapSeverity shape). ConditionId is {sourceNode}#active — matches a single active-branch per alarm which is all ALMD supports; when Cfg_ProgTime-based branch identity becomes interesting (re-raise after ack with new timestamp), a richer ConditionId pass can land. Subscribe-while-disabled returns a handle wrapping id=0 — capability negotiation (the server queries IAlarmSource presence at driver-load time) still succeeds, the alarm surface just never fires. Unsubscribe cancels the sub's CTS + awaits its loop; ShutdownAsync cancels every sub on its way out so a driver reload doesn't leak poll tasks. AcknowledgeAsync routes through the driver's existing WriteAsync path — per-ack writes {SourceNodeId}.Acked = true (the simpler semantic; operators whose ladder watches AckCmd + rising-edge can wire a client-side pulse until a driver-level edge-mode knob lands). Best-effort — per-ack faults are swallowed so one bad ack doesn't poison the whole batch. Six new AbCipAlarmProjectionTests: detector flags ALMD signature + skips non-signature UDTs + atomics; severity mapping matches OPC UA A&C bucket boundaries; feature-flag OFF returns a handle but never touches the fake runtime (proving no background polling happens); feature-flag ON fires a raise event on 0→1; clear event fires on 1→0 after a prior raise; unsubscribe stops the poll loop (ReadCount doesn't grow past cancel + at most one straggler read). Driver builds 0 errors; AbCip.Tests 233/233 (was 227, +6 new). Task #177 closed — the last pending AB CIP follow-up is now #194 (already shipped). Remaining pending fleet-wide: #150 (Galaxy MXAccess failover hardware) + #199 (UnsTab Playwright smoke).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 04:24:40 -04:00
f6d5763448 Merge pull request 'AbCip whole-UDT read optimization (#194)' (#158) from abcip-whole-udt-read into v2 2026-04-20 04:19:51 -04:00
Joseph Doherty
780358c790 AbCip whole-UDT read optimization (#194) — declaration-driven member grouping collapses N per-member reads into one parent-UDT read + client-side decode. Closes task #194. On a batch that includes multiple members of the same hand-declared UDT tag, ReadAsync now issues one libplctag read on the parent + decodes each member from the runtime's buffer at its computed byte offset. A 6-member Motor UDT read goes from 6 libplctag round-trips to 1 — the Rockwell-suggested pattern for minimizing CIP request overhead on batch reads of UDT state (decision #11's follow-through on what the template decoder from task #179 was meant to enable). AbCipUdtMemberLayout is a pure-function helper that computes declared-member byte offsets under Logix natural-alignment rules (SInt 1-byte / Int 2-byte / DInt + Real + Dt 4-byte / LInt + ULInt + LReal 8-byte; alignment pad inserted before each member as needed). Opts out for BOOL / String / Structure members — BOOL storage in Logix UDTs packs into a hidden host byte whose position can't be computed from declaration-only info, and String members need length-prefix + STRING[82] fan-out which libplctag already handles via a per-tag DecodeValue path. The CIP Template Object shape from task #179 (when populated via FetchUdtShapeAsync) carries real offsets for those members — layering that richer path on top of the planner is a separate follow-up and does not change this PR's conservative behaviour. AbCipUdtReadPlanner is the scheduling function ReadAsync consults each batch — pure over (requests, tagsByName), emits Groups + Fallbacks. A group is formed when (a) the reference resolves to "parent.member"; (b) parent is a Structure tag with declared Members; (c) the layout helper succeeds on those members; (d) the specific member appears in the computed offset map; (e) at least two members of the same parent appear in the batch — single-member groups demote to the fallback path because one whole-UDT read vs one per-member read is equivalent cost but more client-side work. Original batch indices are preserved through the plan so out-of-order batches write decoded values back at the right output slot; the caller's result array order is invariant. IAbCipTagRuntime.DecodeValueAt(AbCipDataType, int offset, int? bitIndex) is the new hot-path method — LibplctagTagRuntime delegates to libplctag's offset-aware Get*(offset) calls (GetInt32, GetFloat32, etc.) that were always there; previously every call passed offset 0. DecodeValue(type, bitIndex) stays as the shorthand + forwards to DecodeValueAt with offset 0, preserving the existing single-tag read path + every test that exercises it. FakeAbCipTag gains a ValuesByOffset dictionary so tests can drive multi-member decoding by setting offset→value before the read fires; unmapped offsets fall back to the existing Value field so the 200+ existing tests that never set ValuesByOffset keep working unchanged. AbCipDriver.ReadAsync refactored: planner splits the batch, ReadGroupAsync handles each UDT group (one EnsureTagRuntimeAsync on the parent + one ReadAsync + N DecodeValueAt calls), ReadSingleAsync handles each fallback (the pre-#194 per-tag path, now extracted + threaded through). A per-group failure stamps the mapped libplctag status across every grouped member only — sibling groups + fallback refs are unaffected. Health-surface updates happen once per successful group rather than once per member to avoid ping-ponging the DriverState bookkeeping. Five AbCipUdtMemberLayoutTests: packed atomics get natural-alignment offsets including 8-byte pad before LInt; SInts pack without padding; BOOL/String/Structure opt out + return null; empty member list returns null. Six AbCipUdtReadPlannerTests: two members group; single-member demotes to fallback; unknown references fall back without poisoning groups; atomic top-level tags fall back untouched; UDTs containing BOOL don't group; original indices survive out-of-order batches. Five AbCipDriverWholeUdtReadTests (real driver + fake runtime): two grouped members trigger exactly one parent read + one fake runtime (proving the optimization engages); each member decodes at its own offset via ValuesByOffset; parent-read non-zero status stamps Bad across the group; mixed UDT-member + atomic top-level batch produces 2 runtimes + 2 reads (not 3); single-member-of-UDT still uses the member-level runtime (proving demotion works). Driver builds 0 errors; AbCip.Tests 227/227 (was 211, +16 new).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 04:17:57 -04:00
1ac87f1fac Merge pull request 'ADR-001 last-mile � Program.cs composes walker into production boot (#214)' (#157) from equipment-content-wiring into v2 2026-04-20 03:52:30 -04:00
Joseph Doherty
432173c5c4 ADR-001 last-mile — Program.cs composes EquipmentNodeWalker into the production boot path. Closes task #214 + fully lands ADR-001 Option A as a live code path, not just a connected set of unit-tested primitives. After this PR a server booted against a real Config DB with Published Equipment rows materializes the UNS tree into the OPC UA address space on startup — the whole walker → wire-in → loader chain (PRs #153, #154, #155, #156) finally fires end-to-end in the production process. DriverEquipmentContentRegistry is the handoff between OpcUaServerService's bootstrap-time populate pass + OpcUaApplicationHost's StartAsync walker invocation. It's a singleton mutable holder with Get/Set/Count + Lock-guarded internal dictionary keyed OrdinalIgnoreCase to match the DriverInstanceId convention used by Equipment / Tag rows + walker grouping. Set-once-per-bootstrap semantics in practice though nothing enforces that at the type level — OpcUaServerService.PopulateEquipmentContentAsync is the only expected writer. Shared-mutable rather than immutable-passed-by-value because the DI graph builds OpcUaApplicationHost before NodeBootstrap has resolved the generation, so the registry must exist at compose time + fill at boot time. Program.cs now registers OpcUaApplicationHost via a factory lambda that threads registry.Get as the equipmentContentLookup delegate PR #155 added to the ctor seam — the one-line composition the earlier PR promised. EquipmentNamespaceContentLoader (from PR #156) is AddScoped since it takes the scoped OtOpcUaConfigDbContext; the populate pass in OpcUaServerService opens one IServiceScopeFactory scope + reuses the same loader + DbContext across every driver query rather than scoping-per-driver. OpcUaServerService.ExecuteAsync gets a new PopulateEquipmentContentAsync step between bootstrap + StartAsync: iterates DriverHost.RegisteredDriverIds, calls loader.LoadAsync per driver at the bootstrapped generationId, stashes non-null results in the registry. Null results are skipped — the wire-in's null-check treats absent registry entries as "this driver isn't Equipment-kind; let DiscoverAsync own the address space" which is the correct backward-compat path for Modbus / AB CIP / TwinCAT / FOCAS. Guarded on result.GenerationId being non-null — a fleet with no Published generation yet boots cleanly into a UNS-less address space and fills the registry on the next restart after first publish. Ctor on OpcUaServerService gained two new dependencies (DriverEquipmentContentRegistry + IServiceScopeFactory). No test file constructs OpcUaServerService directly so no downstream test breakage — the BackgroundService is only wired via DI in Program.cs. Four new DriverEquipmentContentRegistryTests: Get-null-for-unknown, Set-then-Get, case-insensitive driver-id lookup, Set-overwrites-existing. Server.Tests 190/190 (was 186, +4 new registry tests). Full ADR-001 Option A now lives at every layer: Core.OpcUa walker (#153) → ScopePathIndexBuilder (#154) → OpcUaApplicationHost wire-in (#155) → EquipmentNamespaceContentLoader (#156) → this PR's registry + Program.cs composition. The last pending loose end (full-integration smoke test that boots Program.cs against a seeded Config DB + verifies UNS tree via live OPC UA client) isn't strictly necessary because PR #155's OpcUaEquipmentWalkerIntegrationTests already proves the wire-in at the OPC UA client-browse level — the Program.cs composition added here is purely mechanical + well-covered by the four-file audit trail plus registry unit tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 03:50:37 -04:00
f6d98cfa6b Merge pull request 'EquipmentNamespaceContentLoader � Config-DB loader for walker wire-in' (#156) from equipment-content-loader into v2 2026-04-20 03:21:48 -04:00
Joseph Doherty
a29828e41e EquipmentNamespaceContentLoader — Config-DB loader that fills the (driverInstanceId, generationId) shape the walker wire-in from PR #155 consumes. Narrow follow-up to PR #155: the ctor plumbing on OpcUaApplicationHost already takes a Func<string, EquipmentNamespaceContent?>? lookup; this PR lands the loader that will back that lookup against the central Config DB at SealedBootstrap time. DI composition in Program.cs is a separate structural PR because it needs the generation-resolve chain restructured to run before OpcUaApplicationHost construction — this one just lands the loader + unit tests so the wiring PR reduces to one factory lambda. Loader scope is one driver instance at one generation: joins Equipment filtered by (DriverInstanceId == driver, GenerationId == gen, Enabled) first, then UnsLines reachable from those Equipment rows, then UnsAreas reachable from those lines, then Tags filtered by (DriverInstanceId == driver, GenerationId == gen). Returns null when the driver has no Equipment at the supplied generation — the wire-in's null-check treats that as "skip the walker; let DiscoverAsync own the whole address space" which is the correct backward-compat behavior for non-Equipment-kind drivers (Modbus / AB CIP / TwinCAT / FOCAS whose namespace-kind is native per decisions #116-#121). Only loads the UNS branches that actually host this driver's Equipment — skips pulling unrelated UNS folders from other drivers' regions of the cluster by deriving lineIds/areaIds from the filtered Equipment set rather than reloading the full UNS tree. Enabled=false Equipment are skipped at the query level so a decommissioned machine doesn't produce a phantom browse folder — Admin still sees it in the diff view via the regular Config-DB queries but the walker's browse output reflects the operational fleet. AsNoTracking on every query because the bootstrap flow is read-only + the result is handed off to a pure-function walker immediately; change tracking would pin rows in the DbContext for the full server lifetime with no corresponding write path. Five new EquipmentNamespaceContentLoaderTests using InMemoryDatabase: (a) null result when driver has no Equipment; (b) baseline happy-path loads the full shape correctly; (c) other driver's rows at the same generation don't leak into this driver's result (per-driver scope contract); (d) same-driver rows at a different generation are skipped (per-generation scope contract per decision #148); (e) Enabled=false Equipment are skipped. Server project builds 0 errors; Server.Tests 186/186 (was 181, +5 new loader tests). Once the wiring PR lands the factory lambda in Program.cs the loader closes over the SealedBootstrap-resolved generationId + the lookup delegate delegates to LoadAsync via IServiceScopeFactory — a one-line composition, no ctor-signature churn on OpcUaApplicationHost because PR #155 already established the seam.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 03:19:45 -04:00
f5076b4cdd Merge pull request 'ADR-001 wire-in � EquipmentNodeWalker in OpcUaApplicationHost (#212 + #213)' (#155) from equipment-walker-wire-in into v2 2026-04-20 03:11:35 -04:00
Joseph Doherty
2d97f241c0 ADR-001 wire-in — EquipmentNodeWalker runs inside OpcUaApplicationHost before driver DiscoverAsync, closing tasks #212 + #213. Completes the in-server half of the ADR-001 Option A story: Task A (PR #153) shipped the pure-function walker in Core.OpcUa; Task B (PR #154) shipped the NodeScopeResolver + ScopePathIndexBuilder + evaluator-level authz proof. This PR lands the BuildAddressSpaceAsync wire-in the walker was always meant to plug into + a full-stack OPC UA client-browse integration test that proves the UNS folder skeleton is actually visible to real UA clients end-to-end, not just to the RecordingBuilder test double. OpcUaApplicationHost gains an optional ctor parameter equipmentContentLookup of type Func<string, EquipmentNamespaceContent?>? — when supplied + non-null for a driver instance, EquipmentNodeWalker.Walk is invoked against that driver's node manager BEFORE GenericDriverNodeManager.BuildAddressSpaceAsync streams the driver's native DiscoverAsync output on top. Walker-first ordering matters: the UNS Area/Line/Equipment folder skeleton + Identification sub-folders + the five identifier properties (decision #121) are in place so driver-native references (driver-specific tag paths) land ALONGSIDE the UNS tree rather than racing it. Callers that don't supply a lookup (every existing pre-ADR-001 test + the v1 upgrade path) get identical behavior — the null-check is the backward-compat seam per the opt-in design sketched in ADR-001. The lookup delegate is driver-instance-scoped, not server-scoped, so a single server with multiple drivers can serve e.g. one Equipment-kind namespace (Galaxy proxy with a full UNS) alongside several native-kind namespaces (Modbus / AB CIP / TwinCAT / FOCAS that do not have their own UNS because decisions #116-#121 scope UNS to Equipment-kind only). SealedBootstrap.Start will wire this lookup against the Config-DB snapshot loader in a follow-up — the lookup plumbing lands first so that wiring reduces to one-line composition rather than a ctor-signature churn. New OpcUaEquipmentWalkerIntegrationTests spins up a real OtOpcUaServer on a non-default port with an EmptyDriver that registers with zero native content + a lookup that returns a seeded EquipmentNamespaceContent (one area warsaw / one line line-a / one equipment oven-3 / one tag Temperature). An OPC UA client session connects anonymously against the un-secured endpoint, browses the standard hierarchy, + asserts: (a) area folder warsaw contains line-a folder as a child; (b) line folder line-a contains oven-3 folder as a child; (c) equipment folder oven-3 contains EquipmentId + EquipmentUuid + MachineCode identifier properties — ZTag + SAPID correctly absent because the fixture leaves them null per decision #121 skip-when-null behavior; (d) the bound Tag emits a Variable node under the equipment folder with NodeId == Tag.TagConfig (the wire-level driver address) + the client can ReadValue against it end-to-end through the DriverNodeManager dispatch path. Because the EmptyDriver's DiscoverAsync is a no-op the test proves UNS content came from the walker, not the driver — the original ADR-001 question "what actually owns the browse tree" now has a mechanical answer visible at the OPC UA wire level. Test class uses its own port (48500+rand) + per-test PKI root so it runs in parallel with the existing OpcUaServerIntegrationTests fixture (48400+rand) without binding or cert collisions. Server project builds 0 errors; Server.Tests 181/181 (was 179, +2 new full-stack walker tests). Task #212 + #213 closed; the follow-up SealedBootstrap wiring is the natural next pickup because the ctor plumbing lands here + that becomes a narrow downstream PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 03:09:37 -04:00
5811ede744 Merge pull request (#154) - ADR-001 Task B + #195 close-out 2026-04-20 02:52:26 -04:00
Joseph Doherty
1bf3938cdf ADR-001 Task B — NodeScopeResolver full-path + ScopePathIndexBuilder + evaluator-level ACL test closing #195. Two production additions + one end-to-end authz regression test proving the Identification ACL contract the IdentificationFolderBuilder docstring promises. Task A (PR #153) shipped the walker as a pure function that materializes the UNS → Equipment → Tag browse tree + IdentificationFolderBuilder.Build per Equipment. This PR lands the authz half of the walker's story — the resolver side that turns a driver-side full reference into a full NodeScope path (NamespaceId + UnsAreaId + UnsLineId + EquipmentId + TagId) so the permission trie can walk the UNS hierarchy + apply Equipment-scope grants correctly at dispatch time. The actual in-server wiring (load snapshot → call walker during BuildAddressSpaceAsync → swap in the full-path resolver) is split into follow-up task #212 because it's a bigger surface (Server bootstrap + DriverNodeManager override + real OPC UA client-browse integration test). NodeScopeResolver extended with a second constructor taking IReadOnlyDictionary<string, NodeScope> pathIndex — when supplied, Resolve looks up the full reference in the index + returns the indexed scope with every UNS level populated; when absent or on miss, falls back to the pre-ADR-001 cluster-only scope so driver-discovered tags that haven't been indexed yet (between a DiscoverAsync result + the next generation publish) stay addressable without crashing the resolver. Index is frozen into a FrozenDictionary<string, NodeScope> under Ordinal comparer for O(1) hot-path lookups. Thread-safety by immutability — callers swap atomically on generation change via the server's publish pipeline. New ScopePathIndexBuilder.Build in Server.Security takes (clusterId, namespaceId, EquipmentNamespaceContent) + produces the fullReference → NodeScope dictionary by joining Tag → Equipment → UnsLine → UnsArea through up-front dictionaries keyed Ordinal-ignoring-case. Tag rows with null EquipmentId (SystemPlatform-namespace Galaxy tags per decision #120) are excluded from the index; cluster-only fallback path covers them. Broken FKs (Tag references missing Equipment row, or Equipment references missing UnsLine) are skipped rather than crashing — sp_ValidateDraft should have caught these at publish, any drift here is unexpected but non-fatal. Duplicate keys throw InvalidOperationException at bootstrap so corrupt-data drift surfaces up-front instead of producing silently-last-wins scopes at dispatch. End-to-end authz regression test in EquipmentIdentificationAuthzTests walks the full dispatch flow against a Config-DB-style fixture: ScopePathIndexBuilder.Build from the same EquipmentNamespaceContent the EquipmentNodeWalker consumes → NodeScopeResolver with that index → AuthorizationGate + TriePermissionEvaluator → PermissionTrieBuilder with one Equipment-scope NodeAcl grant + a NodeAclPath resolving Equipment ScopeId to (namespace, area, line, equipment). Four tests prove the contract: (a) authorized group Read granted on Identification property; (b) unauthorized group Read denied on Identification property — the #195 contract the IdentificationFolderBuilder docstring promises (the BadUserAccessDenied surfacing happens at the DriverNodeManager dispatch layer which is already wired to AuthorizationGate.IsAllowed → StatusCodes.BadUserAccessDenied in PR #94); (c) Equipment-scope grant cascades to both the Equipment's tag + its Identification properties because they share the Equipment ScopeId — no new scope level for Identification per the builder's Remarks section; (d) grant on oven-3 does NOT leak to press-7 (different equipment under the same UnsLine) proving per-Equipment isolation at dispatch when the resolver populates the full path. NodeScopeResolverTests extended with two new tests covering the indexed-lookup path + fallback-on-miss path; renamed the existing "_For_Phase1" test to "_When_NoIndexSupplied" to match the current framing. Server project builds 0 errors; Server.Tests 179/179 (was 173, +6 new across the two test files). Task #212 captures the remaining in-server wiring work — Server.SealedBootstrap load of EquipmentNamespaceContent, DriverNodeManager override that calls EquipmentNodeWalker during BuildAddressSpaceAsync for Equipment-kind namespaces, and a real OPC UA client-browse integration test. With that wiring + this PR's authz-layer proof, #195's "ACL integration test" line is satisfied at two layers (evaluator + live endpoint) which is stronger than the task originally asked for.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 02:50:27 -04:00
7a42f6d84c Merge pull request (#153) - EquipmentNodeWalker (ADR-001 Task A) 2026-04-20 02:40:58 -04:00
Joseph Doherty
2b2991c593 EquipmentNodeWalker — pure-function UNS tree materialization (ADR-001 Task A, task #210). The walker traverses the Config-DB snapshot for a single Equipment-kind namespace (Areas / Lines / Equipment / Tags) and streams IAddressSpaceBuilder.Folder + Variable + AddProperty calls to materialize the canonical 5-level Unified Namespace browse tree that decisions #116-#121 promise external consumers. Pure function: no OPC UA SDK dependency, no DB access, no state — consumes pre-loaded EF Core row collections + streams into the supplied builder. Server-side wiring (load snapshot → call walker → per-tag capability probe) is Task B's scope, alongside NodeScopeResolver's Config-DB join + the ACL integration test that closes task #195. This PR is the Core.OpcUa primitive the server will consume. Walk algorithm — content is grouped up-front (lines by area, equipment by line, tags by equipment) into OrdinalIgnoreCase dictionaries so the per-level nested foreach stays O(N+M) rather than O(N·M) at each UNS level; orderings are deterministic on Name with StringComparer.Ordinal so diffs across runs (e.g. integration-test assertions) are stable. Areas → Lines → Equipment emitted as Folder nodes with browse-name = Name per decision #120. Under each Equipment folder: five identifier properties per decision #121 (EquipmentId + EquipmentUuid always; MachineCode always — it's a required column on the entity; ZTag + SAPID skipped when null to avoid empty-string property noise); IdentificationFolderBuilder.Build materializes the OPC 40010 sub-folder when HasAnyFields(equipment) returns true, skipped otherwise to avoid a pointless empty folder; then one Variable node per Tag row bound to this Equipment (Tag.EquipmentId non-null matches Equipment.EquipmentId) emitted in Name order. Tags with null EquipmentId are walker-skipped — those are SystemPlatform-kind (Galaxy) tags that take the driver-native DiscoverAsync path per decision #120. DriverAttributeInfo construction: FullName = Tag.TagConfig (driver-specific wire-level address); DriverDataType parsed from Tag.DataType which stores the enum name string per decision #138; unparseable values fall back to DriverDataType.String so a one-off driver-specific type doesn't abort the whole walk (driver still sees the original address at runtime + can surface its own typed value via the variant). Address validation is deliberately NOT done at build time per ADR-001 Option A: unreachable addresses surface as OPC UA Bad status via the natural driver-read failure path at runtime, legible to operators through their Admin UI + OPC UA client inspection. Eight new EquipmentNodeWalkerTests: empty content emits nothing; Area/Line/Equipment folder emission order matches Name-sorted deterministic traversal; five identifier properties appear on Equipment nodes with correct values, ZTag + SAPID skipped when null + emitted when non-null; Identification sub-folder materialized when at least one OPC 40010 field is non-null + omitted when all are null; tags with matching EquipmentId emit as Variable nodes under the Equipment folder in Name order, tags with null EquipmentId walker-skipped; unparseable DataType falls back to String. RecordingBuilder test double captures Folder/Variable/Property calls into a tree structure tests can navigate. Core project builds 0 errors; Core.Tests 190/190 (was 182, +8 new walker tests). No Server/Admin changes — Task B lands the server-side wiring + consumes this walker from DriverNodeManager.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 02:39:00 -04:00
9711d0c097 Merge pull request (#152) - ADR-001 Accepted (Option A) 2026-04-20 02:32:41 -04:00
Joseph Doherty
1ddc13b7fc ADR-001 accepted — Option A (Config-primary walker); Option D (discovery-assist) deferred to v2.1. Spawning Task A + Task B. 2026-04-20 02:30:57 -04:00
Joseph Doherty
97e1f55bbb Draft ADR-001 — Equipment node walker: how driver tags bind to the UNS address space. Frames the decision blocking task #195 (IdentificationFolderBuilder wire-in): the Equipment-namespace browse tree requires a Config-DB-driven walker that traverses UNS → Equipment → Tag + hangs Identification sub-folders + identifier properties, and the open question is how driver-discovered tags bind to the UNS Equipment nodes the walker materializes. Context section documents what already exists (IdentificationFolderBuilder unused; NodeScopeResolver at Phase-1 cluster-only stub; Equipment + UnsArea + UnsLine + Tag tables with decisions #110 #116 #117 #120 #121 already landed as the data-model contract) vs what's missing (the walker itself + the ITagDiscovery/Config-DB composition strategy). Four options laid out with trade-offs: Option A Config-primary (Tag rows are the sole source of truth; ITagDiscovery becomes enrichment; BadNotFound placeholder when driver can't address a declared tag); Option B Discovery-primary (driver output is authoritative; Config-DB Equipment rows select subsets); Option C Parallel namespaces (driver-native ns + UNS overlay ns cross-referencing via OPC UA Organizes); Option D Config-primary-with-discovery-assist (same as A at runtime, plus an Admin UI offline discovery panel that lets operators one-click-import discovered tags into the draft). Recommendation: Option A now, defer Option D to v2.1. Reasons: matches decision #110's framing straight-through, identical composition across every Equipment-kind driver, Phase 6.4 Admin UI already authors Tag rows, BadNotFound is a legible failure mode, and nothing in A blocks adding D later without changing the walker contract. If the ADR is accepted, spawns two tasks: Task A builds EquipmentNodeWalker in Core.OpcUa (cluster → namespace → area → line → equipment → tag traversal, IdentificationFolderBuilder per Equipment, 5 identifier properties, BadNotFound placeholders, integration tests); Task B extends NodeScopeResolver to join against Config DB + populate full NodeScope path (unblocks per-Equipment/per-UnsLine ACL granularity + closes task #195 with the ACL integration test from the builder's docstring cross-reference). Consequences-if-we-don't-decide section captures the status quo: Identification metadata ships in DB + Admin UI but never reaches the OPC UA endpoint, external consumers can't resolve equipment via OPC UA properties as decision #121 promises, and NodeScopeResolver stays cluster-level so finer ACL grants are effectively cluster-wide at dispatch (Phase 6.2 rollout limitation, not correctness bug). Draft status — seeking decision before spawning the two implementation tasks. If accepted I'll add the tasks + start on Task A.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 02:28:10 -04:00
cb2a375548 Merge pull request (#151) - Phase 2 close-out 2026-04-20 02:02:33 -04:00
Joseph Doherty
33b87a3aa4 Phase 2 official close-out. Closes task #209. The 2026-04-18 exit-gate-phase-2-final.md captured Phase 2 state at PR 2 merge — four High/Medium adversarial findings still OPEN, Historian port + alarm subsystem + v1 archive deletion all deferred. Since then: PR 4 closed all four findings end-to-end (High 1 Read subscription-leak, High 2 no reconnect loop, Medium 3 SubscribeAsync doesn't push frames, Medium 4 WriteValuesAsync doesn't await OnWriteComplete — mapped + resolved inline in the new doc), PR 12 landed the richer historian quality mapper, PR 13 shipped GalaxyRuntimeProbeManager with per-Platform/AppEngine ScanState subscriptions + StateChanged events forwarded through the existing OnHostStatusChanged IPC frame, PR 14 wired the alarm subsystem (GalaxyAlarmTracker advising the four alarm-state attributes per IsAlarm=true attribute, raising AlarmTransition events forwarded through OnAlarmEvent IPC frames), Phase 3 PR 18 deleted the v1 source trees, and PR 61 closed V1_ARCHIVE_STATUS.md. Phase 2 is functionally done; this commit is the bookkeeping pass. New exit-gate-phase-2-closed.md at docs/v2/implementation/ — five-stream status table (A/B/C/D/E all complete with the specific close commits named), full resolution table for every 2026-04-18 adversarial finding mapped to the PR 4 resolution, cross-cutting deferrals table marking every one resolved (Historian SDK plugin port → done, subscription push frames → done under Medium 3, Historian-backed HistoryRead → done, alarm subsystem wire-up → done, reconnect-without-recycle → done under High 2, v1 archive deletion → done). Fresh 2026-04-20 test baseline captured from the current v2 tip: 1844 passing + 29 infra-gated skips across 21 test projects, including the net48 x86 Galaxy.Host.Tests suite (107 pass) that exercises the MXAccess COM path on the dev box. Flake observed — Configuration.Tests 70/71 on first full-solution run, 71/71 on retry; logged as a known non-stable flake rather than chased because it did not reproduce. The prior exit-gate-phase-2-final.md is kept in place (historical record of the 2026-04-18 snapshot) but gets a superseded-by banner at the top pointing at the new close-out doc so future readers land on current status first. docs/v2/plan.md Phase 2 section header gains the CLOSED 2026-04-20 marker + a link to the close-out doc so the top-level plan index reflects reality. "What Phase 2 closed means for Phase 3 and later" section in the new doc captures the downstream contract: Galaxy now runs as a first-class v2 driver with the same capability-interface shape as Modbus / S7 / AbCip / AbLegacy / TwinCAT / FOCAS / OpcUaClient; no v1 code path remains; the 2026-04-13 stability findings persist as named regression tests under tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/StabilityFindingsRegressionTests.cs so any future refactor reintroducing them trips the test. "Outstanding — not Phase 2 blockers" section lists the four pending non-Phase-2 tasks (#177, #194, #195, #199) so nobody mistakes them for Phase 2 tail work.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 02:00:35 -04:00
2391de7f79 Merge pull request (#150) - Client rename residuals (#207 + #208) 2026-04-20 01:52:40 -04:00
Joseph Doherty
f9bc301c33 Client rename residuals: lmxopcua-cli → otopcua-cli + LmxOpcUaClient → OtOpcUaClient with migration shim. Closes task #208 (the executable-name + LocalAppData-folder slice that was called out in Client.CLI.md / Client.UI.md as a deliberately-deferred residual of the Phase 0 rename). Six source references flipped to the canonical OtOpcUaClient spelling: Program.cs CliFx executable name + description (lmxopcua-cli → otopcua-cli), DefaultApplicationConfigurationFactory.cs ApplicationName + ApplicationUri (LmxOpcUaClient + urn:localhost:LmxOpcUaClient → OtOpcUaClient + urn:localhost:OtOpcUaClient), OpcUaClientService.CreateSessionAsync session-name arg, ConnectionSettings.CertificateStorePath default, MainWindowViewModel.CertificateStorePath default, JsonSettingsService.SettingsDir. Two consuming tests (ConnectionSettingsTests + MainWindowViewModelTests) updated to assert the new canonical name. New ClientStoragePaths static helper at src/ZB.MOM.WW.OtOpcUa.Client.Shared/ClientStoragePaths.cs is the migration shim — single entry point for the PKI root + pki subpath, runs a one-shot legacy-folder probe on first resolution: if {LocalAppData}/LmxOpcUaClient/ exists + {LocalAppData}/OtOpcUaClient/ does not, Directory.Move renames it in place (atomic on NTFS within the same volume) so trusted server certs + saved connection settings persist across the rename without operator action. Idempotent per-process via a Lock-guarded _migrationChecked flag so repeated CertificateStorePath getter calls on the hot path pay no IO cost beyond the first. Fresh-install path (neither folder exists) + already-migrated path (only canonical exists) + manual-override path (both exist — developer has set up something explicit) are all no-ops that leave state alone. IOException on the Directory.Move is swallowed + logged as a false return so a concurrent peer process losing the race doesn't crash the consumer; the losing process falls back to whatever state exists. Five new ClientStoragePathsTests assert: GetRoot ends with canonical name under LocalAppData, GetPkiPath nests pki under root, CanonicalFolderName is OtOpcUaClient, LegacyFolderName is LmxOpcUaClient (the migration contract — a typo here would leak the legacy folder past the shim), repeat invocation returns false after first-touch arms the in-process guard. Doc-side residual-explanation notes in docs/Client.CLI.md + docs/Client.UI.md are dropped now that the rename is real; replaced with a short "pre-#208 dev boxes migrate automatically on first launch" note that points at ClientStoragePaths. Sample CLI invocations in Client.CLI.md updated via sed from lmxopcua-cli to otopcua-cli across every command block (14 replacements). Pre-existing staleness in SubscribeCommandTests.Execute_PrintsSubscriptionMessage surfaced during the test run — the CLI's subscribe command has long since switched to an aggregate "Subscribed to {count}/{total} nodes (interval: ...)" output format but the test still asserted the original single-node form. Updated the assertion to match current output + added a comment explaining the change; this is unrelated to the rename but was blocking a green Client.CLI.Tests run. Full solution build 0 errors; Client.Shared.Tests 136/136 + 5 new shim tests passing; Client.UI.Tests 98/98; Client.CLI.Tests 52/52 (was 51/52 before the subscribe-test fix). No Admin/Core/Server changes — this touches only the client layer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 01:50:40 -04:00
Joseph Doherty
12d748c4f3 CLAUDE.md — TopShelf + LdapAuthenticationProvider stale references. Closes task #207. The docs-refresh agent sweep (PR #149) flagged two stale library/class references in the root CLAUDE.md that the v2 refactors landed but the project-level instructions missed. Service hosting line replaced with the two-process reality: Server + Admin use .NET generic-host AddWindowsService (decision #30 explicitly replaced TopShelf in v2 — OpcUaServerService.cs carries the decision-#30 comment inline); Galaxy.Host is a plain console app wrapped by NSSM because its .NET-Framework-4.8-x86 target can't use the generic-host Windows-service integration + MXAccess COM bitness requirement pins it there anyway. The LDAP-auth mention gains the actual class name LdapUserAuthenticator (src/ZB.MOM.WW.OtOpcUa.Server/Security/LdapUserAuthenticator.cs) implementing IUserAuthenticator — previously claimed LdapAuthenticationProvider + IUserAuthenticationProvider + IRoleProvider, none of which exist in the source tree (the docs-refresh agent grepped for it; it's truly gone). No functional impact — CLAUDE.md is operator-facing + informs future agent runs about the stack, not compile-time.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 01:41:16 -04:00
e9b1d107ab Merge pull request (#149) - Doc refresh for multi-driver OtOpcUa 2026-04-20 01:37:00 -04:00
44 changed files with 3091 additions and 117 deletions

View File

@@ -87,13 +87,14 @@ The server supports non-transparent warm/hot redundancy via the `Redundancy` sec
## LDAP Authentication
The server uses LDAP-based user authentication via the `Authentication.Ldap` section in `appsettings.json`. When enabled, credentials are validated by LDAP bind against a GLAuth server (installed at `C:\publish\glauth\`), and LDAP group membership maps to OPC UA permissions: `ReadOnly` (browse/read), `WriteOperate` (write FreeAccess/Operate attributes), `WriteTune` (write Tune attributes), `WriteConfigure` (write Configure attributes), `AlarmAck` (alarm acknowledgment). `LdapAuthenticationProvider` implements both `IUserAuthenticationProvider` and `IRoleProvider`. See `docs/Security.md` for the full guide and `C:\publish\glauth\auth.md` for LDAP user/group reference.
The server uses LDAP-based user authentication via the `Authentication.Ldap` section in `appsettings.json`. When enabled, credentials are validated by LDAP bind against a GLAuth server (installed at `C:\publish\glauth\`), and LDAP group membership maps to OPC UA permissions: `ReadOnly` (browse/read), `WriteOperate` (write FreeAccess/Operate attributes), `WriteTune` (write Tune attributes), `WriteConfigure` (write Configure attributes), `AlarmAck` (alarm acknowledgment). `LdapUserAuthenticator` (`src/ZB.MOM.WW.OtOpcUa.Server/Security/LdapUserAuthenticator.cs`) implements `IUserAuthenticator`. See `docs/Security.md` for the full guide and `C:\publish\glauth\auth.md` for LDAP user/group reference.
## Library Preferences
- **Logging**: Serilog with rolling daily file sink
- **Unit tests**: xUnit + Shouldly for assertions
- **Service hosting**: TopShelf (Windows service install/uninstall/run as console)
- **Service hosting (Server, Admin)**: .NET generic host with `AddWindowsService` (decision #30 — replaced TopShelf in v2; see `src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs`)
- **Service hosting (Galaxy.Host)**: plain console app wrapped by NSSM (`.NET Framework 4.8 x86` — required by MXAccess COM bitness)
- **OPC UA**: OPC Foundation UA .NET Standard stack (https://github.com/opcfoundation/ua-.netstandard) — NuGet: `OPCFoundation.NetStandard.Opc.Ua.Server`
## OPC UA .NET Standard Documentation

View File

@@ -14,7 +14,7 @@ dotnet build
dotnet run -- <command> [options]
```
The executable name is still `lmxopcua-cli` a residual from the pre-v2 rename (`Program.cs:SetExecutableName`). Scripts + operator muscle memory depend on the name; flipping it to `otopcua-cli` is a follow-up that also needs to move the client-side PKI store folder (<code>{LocalAppData}/LmxOpcUaClient/pki/</code> — used by the shared client for its application certificate) so trust relationships survive the rename.
The executable name is `otopcua-cli`. Dev boxes carrying a pre-task-#208 install may still have the legacy `{LocalAppData}/LmxOpcUaClient/` folder on disk; on first launch of any post-#208 CLI or UI build, `ClientStoragePaths` (`src/ZB.MOM.WW.OtOpcUa.Client.Shared/ClientStoragePaths.cs`) migrates it to `{LocalAppData}/OtOpcUaClient/` automatically so trusted certificates + saved settings survive the rename.
## Architecture
@@ -46,7 +46,7 @@ All commands accept these options:
When `-U` and `-P` are provided, the shared service passes a `UserIdentity(username, password)` to the OPC UA session. Without credentials, anonymous identity is used.
```bash
lmxopcua-cli write -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -v 42 -U operator -P op123
otopcua-cli write -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -v 42 -U operator -P op123
```
### Failover
@@ -54,20 +54,20 @@ lmxopcua-cli write -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -v 42 -U opera
When `-F` is provided, the shared service tries the primary URL first, then each failover URL in order. For long-running commands (`subscribe`, `alarms`), the service monitors the session via keep-alive and automatically reconnects to the next available server on failure.
```bash
lmxopcua-cli connect -u opc.tcp://localhost:4840/OtOpcUa -F opc.tcp://localhost:4841/OtOpcUa
otopcua-cli connect -u opc.tcp://localhost:4840/OtOpcUa -F opc.tcp://localhost:4841/OtOpcUa
```
### Transport Security
When `sign` or `encrypt` is specified, the shared service:
1. Ensures a client application certificate exists under `{LocalAppData}/LmxOpcUaClient/pki/` (auto-created if missing)
1. Ensures a client application certificate exists under `{LocalAppData}/OtOpcUaClient/pki/` (auto-created if missing; pre-rename `LmxOpcUaClient/` is migrated in place on first launch)
2. Discovers server endpoints and selects one matching the requested security mode
3. Prefers `Basic256Sha256` when multiple matching endpoints exist
4. Fails with a clear error if no matching endpoint is found
```bash
lmxopcua-cli browse -u opc.tcp://localhost:4840/OtOpcUa -S encrypt -U admin -P secret -r -d 2
otopcua-cli browse -u opc.tcp://localhost:4840/OtOpcUa -S encrypt -U admin -P secret -r -d 2
```
### Verbose Logging
@@ -81,7 +81,7 @@ The `--verbose` flag switches Serilog output from `Warning` to `Debug` level, sh
Tests connectivity to an OPC UA server. Creates a session, prints connection metadata, and disconnects.
```bash
lmxopcua-cli connect -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123
otopcua-cli connect -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123
```
Output:
@@ -99,7 +99,7 @@ Connection successful.
Reads the current value of a single node and prints the value, status code, and timestamps.
```bash
lmxopcua-cli read -u opc.tcp://localhost:4840/OtOpcUa -n "ns=3;s=DEV.ScanState" -U admin -P admin123
otopcua-cli read -u opc.tcp://localhost:4840/OtOpcUa -n "ns=3;s=DEV.ScanState" -U admin -P admin123
```
| Flag | Description |
@@ -121,7 +121,7 @@ Server Time: 2026-03-30T19:58:38.0971257Z
Writes a value to a node. The shared service reads the current value first to determine the target data type, then converts the supplied string value using `ValueConverter.ConvertValue()`.
```bash
lmxopcua-cli write -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -v 42
otopcua-cli write -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -v 42
```
| Flag | Description |
@@ -135,10 +135,10 @@ Browses the OPC UA address space starting from the Objects folder or a specified
```bash
# Browse top-level Objects folder
lmxopcua-cli browse -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123
otopcua-cli browse -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123
# Browse a specific node recursively to depth 3
lmxopcua-cli browse -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123 -r -d 3 -n "ns=3;s=ZB"
otopcua-cli browse -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123 -r -d 3 -n "ns=3;s=ZB"
```
| Flag | Description |
@@ -152,7 +152,7 @@ lmxopcua-cli browse -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123 -r
Monitors a node for value changes using OPC UA subscriptions. Prints each data change notification with timestamp, value, and status code. Runs until Ctrl+C, then unsubscribes and disconnects cleanly.
```bash
lmxopcua-cli subscribe -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -i 500
otopcua-cli subscribe -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -i 500
```
| Flag | Description |
@@ -166,12 +166,12 @@ Reads historical data from a node. Supports raw history reads and aggregate (pro
```bash
# Raw history
lmxopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa \
otopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa \
-n "ns=1;s=TestMachine_001.TestHistoryValue" \
--start "2026-03-25" --end "2026-03-30"
# Aggregate: 1-hour average
lmxopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa \
otopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa \
-n "ns=1;s=TestMachine_001.TestHistoryValue" \
--start "2026-03-25" --end "2026-03-30" \
--aggregate Average --interval 3600000
@@ -203,10 +203,10 @@ Subscribes to alarm events on a node. Prints structured alarm output including s
```bash
# Subscribe to alarm events on the Server node
lmxopcua-cli alarms -u opc.tcp://localhost:4840/OtOpcUa
otopcua-cli alarms -u opc.tcp://localhost:4840/OtOpcUa
# Subscribe to a specific source node with condition refresh
lmxopcua-cli alarms -u opc.tcp://localhost:4840/OtOpcUa \
otopcua-cli alarms -u opc.tcp://localhost:4840/OtOpcUa \
-n "ns=1;s=TestMachine_001" --refresh
```
@@ -221,7 +221,7 @@ lmxopcua-cli alarms -u opc.tcp://localhost:4840/OtOpcUa \
Reads the OPC UA redundancy state from a server: redundancy mode, service level, server URIs, and application URI.
```bash
lmxopcua-cli redundancy -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123
otopcua-cli redundancy -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123
```
Example output:

View File

@@ -65,7 +65,7 @@ The top bar provides the endpoint URL, Connect, and Disconnect buttons. The **Co
### Settings Persistence
Connection settings are saved to `{LocalAppData}/LmxOpcUaClient/settings.json` after each successful connection and on window close. The folder name is a residual from the pre-v2 rename (the `Client.Shared` session factory still calls itself `LmxOpcUaClient` at `OpcUaClientService.cs:428`); renaming to `OtOpcUaClient` is a follow-up that needs a migration shim so existing users don't lose their settings on upgrade. The settings are reloaded on next launch, including:
Connection settings are saved to `{LocalAppData}/OtOpcUaClient/settings.json` after each successful connection and on window close. Dev boxes upgrading from a pre-task-#208 build still have the legacy `LmxOpcUaClient/` folder on disk; `ClientStoragePaths` in `Client.Shared` moves it to the canonical path on first launch so existing trusted certs + saved settings persist without operator action. The settings are reloaded on next launch, including:
- All connection parameters
- Active subscription node IDs (restored after reconnection)

View File

@@ -0,0 +1,248 @@
# ADR-001 — Equipment node walker: how driver tags bind to the UNS address space
**Status:** Accepted 2026-04-20 — Option A (Config-primary); Option D deferred to v2.1
**Related tasks:** [#195 IdentificationFolderBuilder wire-in](../../../) (blocked on this)
**Related decisions in `plan.md`:** #110 (Tag belongs to Equipment via FK in Equipment ns),
#116 / #117 / #121 (five identifiers as properties, `Equipment.Name` as path segment),
#120 (UNS hierarchy mandatory in Equipment ns; SystemPlatform ns exempt).
## Context
Today the `DriverNodeManager` builds its address space by calling
`ITagDiscovery.DiscoverAsync` on each registered driver. Every driver returns whatever
browse shape its wire protocol produces — Galaxy returns gobjects with attributes, Modbus
returns whatever tag configs the operator authored, AB CIP returns controller-walk output,
etc. The result is a per-driver subtree, rooted under the driver's own namespace, with no
UNS levels.
The Config DB meanwhile carries the authoritative UNS model for every Equipment-kind
namespace:
```
ConfigGeneration
└─ ServerCluster
└─ Namespace (Kind=Equipment)
└─ UnsArea
└─ UnsLine
└─ Equipment (carries 9 OPC 40010 Identification fields + 5 identifiers)
└─ Tag (EquipmentId FK when Kind=Equipment; DriverInstanceId + FolderPath when Kind=SystemPlatform)
```
Decision #110 already binds `Tag → Equipment` by foreign key. Decision #120 requires the
Equipment-namespace browse tree to conform to `Enterprise/Site/Area/Line/Equipment/TagName`.
The building blocks exist:
- `IdentificationFolderBuilder.Build(equipmentBuilder, row)` — pure function that hangs
nine OPC 40010 properties under an Equipment node. Shipped, untested in integration.
- `Equipment` table rows with `UnsLineId` FK + the 9 identification columns + the 5
identifier columns.
- `Tag` table rows with nullable `EquipmentId` + a `TagConfig` JSON column carrying the
wire-level address.
- `NodeScopeResolver` — Phase-1 stub that returns a cluster-level scope only, with an
explicit "future resolver will join against the Configuration DB" note.
What's missing is the **walker**: server-side code that reads the UNS + Equipment + Tag
rows for the current published generation, traverses them in UNS order, materializes each
level as an OPC UA folder, and wires `IdentificationFolderBuilder.Build` + the 5-identifier
properties under each Equipment node.
The walker isn't pure bookkeeping — it has to decide **how driver-discovered tags bind to
UNS Equipment nodes**. That's the decision this ADR resolves.
## Open question
> For an Equipment-kind driver, is the published OPC UA surface driven by (a) the Config
> DB's `Tag` rows, (b) the driver's `ITagDiscovery.DiscoverAsync` output, or (c) some
> combination?
SystemPlatform-kind drivers (Galaxy only, today) are unambiguous: decision #120 exempts
them from UNS + they keep their v1 native hierarchy. The walker does not touch
SystemPlatform namespaces beyond the existing driver-discovery path. This ADR only decides
Equipment-kind composition.
## Options
### Option A — Config-primary
The `Tag` table is the sole source of truth for what gets published. `ITagDiscovery`
becomes a validation + enrichment surface, not a discovery surface.
**Walker flow:**
1. Read `UnsArea` / `UnsLine` / `Equipment` / `Tag` for the published generation.
2. Walk Area → Line → Equipment, materializing each level as an OPC UA folder.
3. Under each Equipment node:
- Add the 5 identifier properties (`EquipmentId`, `EquipmentUuid`, `MachineCode`,
`ZTag`, `SAPID`) as OPC UA properties per decision #121.
- Call `IdentificationFolderBuilder.Build` to add the `Identification` sub-folder with
the 9 OPC 40010 fields.
- For each `Tag` row bound to this Equipment: ask the driver's `IReadable` /
`IWritable` surface whether it can address `Tag.TagConfig.address`; if yes, create a
variable node. If no, create a `BadNotFound` placeholder with a diagnostic so
operators see the mismatch instead of a silent drop.
4. `ITagDiscovery.DiscoverAsync` is re-purposed to **enrich** — driver may return schema
hints (data type, bounds, description) that operators missed when authoring the Tag
row. The Admin UI surfaces them as "driver suggests" hints for next-draft edits.
**Trade-offs:**
- ✅ Matches decision #110's framing cleanly. `Tag` rows carry the contract; nothing gets
published that's not explicitly authored.
- ✅ Same model for every Equipment-kind driver. Modbus / S7 / AB CIP / AB Legacy /
TwinCAT / FOCAS / OpcUaClient all compose identically.
- ✅ UNS hierarchy is always exactly as-authored. No race between "driver added a tag at
runtime" and "operator hasn't approved it yet."
- ✅ Aligns with the Config-DB-first operator story the Admin UI already tells.
- ❌ Drivers with large native schemas (TwinCAT PLCs with thousands of symbols, AB CIP
controllers with full @tags walkers) can't "just publish everything" — operators must
author Tag rows. This is a pure workflow cost, not a correctness cost.
- ❌ A Tag row whose driver can't address it produces a placeholder node at runtime
(BadNotFound), not a publish-time validation failure. Mitigation: `sp_ValidateDraft`
already validates per-driver references at publish — extend it to call each driver's
existence check, or keep it as runtime-visible with an Admin UI indicator.
### Option B — Discovery-primary
`ITagDiscovery.DiscoverAsync` is the source of truth for what gets published. The walker
joins discovered tags against Config-DB Equipment rows to assemble the UNS tree.
**Walker flow:**
1. Driver runs `ITagDiscovery.DiscoverAsync` — returns its native tag graph.
2. Walker reads `Equipment` + `Tag` rows; uses `Tag.TagConfig.address` to match against
discovered references.
3. For each match: materialize the UNS path + attach the discovered variable under the
bound Equipment node.
4. Discovered tags with no matching `Tag` row: silently dropped (or surfaced under a
`Unmapped/` diagnostic folder).
5. `Tag` rows with no discovered match: hidden (or surfaced as `BadNotFound` placeholder
same as Option A).
**Trade-offs:**
- ✅ Lets drivers with rich discovery (TwinCAT `SymbolLoaderFactory`, AB CIP `@tags`)
publish live controller state without operator-authored Tag rows for every symbol.
- ✅ Driver-native metadata (real OPC UA data types, real bounds) is authoritative.
- ❌ Conflicts with the Config-DB-first publish workflow. Operators publish a generation
+ discover a different set at runtime + the two don't necessarily match. Diff tooling
becomes harder.
- ❌ Galaxy's SystemPlatform-namespace path still uses Option-B-like discovery — so the
codebase would host two compositions regardless. But adding a second
discovery-primary composition for Equipment-kind would double the surface operators
have to reason about.
- ❌ Requires each driver to emit tag identifiers that stably match `Tag.TagConfig.address`
shape across re-discovery. Works for Galaxy (attribute full refs are stable); harder for
AB CIP where the @tags walker may return tags operators haven't declared.
- ❌ Operator-visible symptom of "my tag didn't publish" splits between two places: the
Tag row exists (Config DB) + the driver can't find it (runtime discovery). Option A
surfaces the same gap as a single `BadNotFound` placeholder; B multiplies it.
### Option C — Parallel namespaces
Driver tags are always published under a driver-native folder hierarchy (discovery-driven,
same as today). A secondary UNS "view" namespace is overlaid, containing Equipment nodes
with Identification sub-folders + `Organizes` references pointing at the driver-native tag
nodes.
**Walker flow:**
1. Driver's native discovery publishes `ns=2;s={DriverInstanceId}/{...driver shape}` as
today.
2. Walker reads UNS + Equipment + Tag rows.
3. For each Equipment, creates a node under the UNS namespace (`ns=3;s=UNS/Site/Area/Line/Equipment`)
+ adds Identification properties + creates `Organizes` references from the Equipment
node to the matching driver-native variable nodes.
**Trade-offs:**
- ✅ Preserves the discovery-first driver shape — no change to what Modbus / S7 / AB CIP
publish natively; those projects keep working identically.
- ✅ UNS tree becomes an overlay that operators can opt into or out of. External consumers
that want UNS addressing browse via the UNS namespace; consumers that want driver-native
addressing keep using the driver namespace.
- ❌ Doubles the OPC UA node count for every Equipment-kind tag (one node in driver ns,
one reference in UNS ns). OPC UA clients handle it but it inflates browse-result sizes.
- ❌ Contradicts decision #120: "Equipment namespace browse paths must conform to the
canonical 5-level Unified Namespace structure." Option C makes the driver namespace
browse path NOT conform; the UNS namespace is a second view. An external client that
reads the Equipment namespace in driver-native shape doesn't see UNS at all.
- ❌ Identification ACL semantics get complicated — the sub-folder lives in the UNS ns,
but the tag data lives in the driver ns. Two different scope ids; two grants to author.
### Option D — Config-primary with driver-discovery-assist
Same as Option A, but `ITagDiscovery.DiscoverAsync` is called during *draft authoring*
(not at server runtime) to populate an Admin UI "discovered tags available" panel that
operators can one-click-add to the draft Tag table. At publish time the Tag rows drive
the server as in Option A — discovery runs only as an offline helper.
**Trade-offs:**
- ✅ Keeps Option A's runtime semantics — Config DB is the sole publish-time truth.
- ✅ Closes Option A's only real workflow weakness (authoring Tag rows for large
controllers) by letting operators import discovered tags with a click.
- ✅ Draws a clean line between author-time discovery (optional, offline) and publish-time
resolution (strict, Config-DB-driven).
- ❌ Adds work that isn't on the Phase 6.4 checklist — Admin UI needs a "pull discovered
tags from this driver" flow, which means the Admin host needs to proxy a DiscoverAsync
call through the Server process (or directly into the driver — more complex deployment
topology). v2.1 work, not v2.
## Recommendation
**Pick Option A.** Ship the walker as Config-primary immediately; defer Option D's
Admin-UI discovery-assist to v2.1 once the walker is proven.
Reasons:
1. **Decision #110 already points here.** `Tag.EquipmentId` + `Tag.TagConfig` are the
published contract. Option A is the straight-line implementation of that contract.
2. **Identical composition across seven drivers.** Every Equipment-kind driver uses the
same walker code path. New drivers (e.g. a future OPC UA Client gateway mode) plug in
without touching the walker.
3. **Phase 6.4 Admin UI already authors Tag rows.** CSV import, UnsTab drag/drop, draft
diff — all operate on Tag rows. The walker being Tag-row-driven means the Admin UI
and the server see the same surface.
4. **BadNotFound is a clean failure mode.** An operator publishes a Tag row whose
address the driver can't reach → client sees a `BadNotFound` variable with a
diagnostic, operator fixes the Tag row + republishes. This is legible + easy to
debug. Options B and C smear the failure across multiple namespaces.
5. **Option D is additive, not alternative.** Nothing in A blocks adding D later; the
walker contract stays the same, Admin UI just gets a discovery-assist panel.
The walker implementation lands under two tasks this ADR spawns (if accepted):
- **Task A** — Build `EquipmentNodeWalker` in `Core.OpcUa` that drives the
`ClusterNode → Namespace → UnsArea → UnsLine → Equipment → Tag` traversal, calls
`IdentificationFolderBuilder.Build` per Equipment, materializes the 5 identifier
properties, and creates variable nodes for each bound Tag row. Writes integration
tests covering the happy path + BadNotFound placeholder.
- **Task B** — Extend `NodeScopeResolver` to join against Config DB + populate the
full `NodeScope` path (UnsAreaId / UnsLineId / EquipmentId / TagId). Unblocks the
Phase 6.2 finer-grained ACL (per-Equipment, per-UnsLine grants). Add ACL integration
test per task #195 — browse `Equipment/Identification` as unauthorized user,
assert `BadUserAccessDenied`.
Task #195 closes on Task B's landing.
## Consequences if we don't decide
- Task #195 stays blocked. The `IdentificationFolderBuilder` exists but is dead code
reachable only from its unit tests.
- `NodeScopeResolver` stays at cluster-level scope. Per-Equipment / per-UnsLine ACL
grants work at the Admin UI authoring layer + the data-plane evaluator, but the
runtime scope resolution never populates anything below `ClusterId + TagId` — so
finer grants are effectively cluster-wide at dispatch. Phase 6.2's rollout plan calls
this out as a rollout limitation; it's not a correctness bug but it's a feature gap.
- Equipment metadata (the 9 OPC 40010 fields, the 5 identifiers) ships in the Config DB
+ the Admin UI editor but never surfaces on the OPC UA endpoint. External consumers
(ERP, SAP PM) can't resolve equipment via OPC UA properties as decision #121 promises.
## Next step
Accept this ADR + spawn Task A + Task B.
If the recommendation is rejected, the alternative options (B / C / D) are ranked by
implementation cost in the Trade-offs sections above. My strong preference is A + defer D.

View File

@@ -0,0 +1,108 @@
# Phase 2 Close-Out (2026-04-20)
> Supersedes `exit-gate-phase-2-final.md` (2026-04-18) which captured the state at PR 2
> merge. Between that doc and today, PR 4 closed all open high + medium findings, PR 13
> shipped the probe manager, PR 14 shipped the alarm subsystem, and PR 61 closed the v1
> archive deletion. Phase 2 is closed.
## Status: **CLOSED**
Every stream in Phase 2 is complete. Every finding from the 2026-04-18 adversarial review
is resolved. The v1 archive is deleted. The Galaxy driver runs the full
`Shared` / `Host` / `Proxy` topology against live MXAccess on the dev box with all 9
capability interfaces wired end-to-end.
## Stream-by-stream
| Stream | Plan §reference | Status | Close commit |
|---|---|---|---|
| A — Driver.Galaxy.Shared | §A.1A.3 | ✅ Complete | PR 1 |
| B — Driver.Galaxy.Host | §B.1B.10 | ✅ Complete — real Win32 pump, Tier C protections, all 3 IGalaxyBackend impls (Stub / DbBacked / MxAccess), probe manager, alarm tracker, Historian wire-up | PR 1 + PR 4 + PR 12 + PR 13 + PR 14 |
| C — Driver.Galaxy.Proxy | §C.1C.4 | ✅ Complete — all 9 capability interfaces, supervisor (Backoff + CircuitBreaker + HeartbeatMonitor), subscription push frames | PR 1 + PR 4 |
| D — Retire legacy Host | §D.1D.3 | ✅ Complete — archive markings landed in PR 2, source tree deletion in Phase 3 PR 18, status doc closed in PR 61 | PR 2 → Phase 3 PR 18 → PR 61 |
| E — Parity validation | §E.1E.4 | ✅ Complete — E2E suite + 4 stability-finding regression tests + `HostSubprocessParityTests` cross-FX integration | PR 2 |
## 2026-04-18 adversarial findings — resolved
All four `High` + `Medium` items flagged as OPEN at the 2026-04-18 exit gate closed in PR 4
(`caa9cb8 Phase 2 PR 4 — close the 4 open high/medium MXAccess findings from
exit-gate-phase-2-final.md`):
| ID | Finding | Resolution |
|----|---------|------------|
| High 1 | MxAccess Read subscription-leak on cancellation | One-shot read now wraps subscribe → first `OnDataChange` → unsubscribe in try/finally. Per-tag callback always detached. If the read installed the underlying subscription (prior `_addressToHandle` key was absent) it tears it down on the way out — no leaked probe item handles on caller cancel or timeout. |
| High 2 | No MXAccess reconnect loop, only supervisor-driven recycle | `MxAccessClient` gains `MxAccessClientOptions { AutoReconnect, MonitorInterval=5s, StaleThreshold=60s }` + a background `MonitorLoopAsync` started on first `ConnectAsync`. Checks `_lastObservedActivityUtc` each interval (bumped by every `OnDataChange` callback); if stale, probes the proxy with a no-op COM `AddItem("$Heartbeat")` on the StaPump; on probe failure does reconnect-with-replay — Unregister (best-effort), Register, snapshot `_addressToHandle.Keys`, clear, re-AddItem every previously-active subscription. `ConnectionStateChanged` fires on the false→true transition; `ReconnectCount` bumps. |
| Medium 3 | `SubscribeAsync` doesn't push `OnDataChange` frames yet | `IGalaxyBackend` gains `OnDataChange` / `OnAlarmEvent` / `OnHostStatusChanged` events. New `IFrameHandler.AttachConnection(FrameWriter)` called per-connection by `PipeServer` after Hello. `GalaxyFrameHandler.ConnectionSink` subscribes the events for the connection lifetime, fire-and-forgets pushes as `MessageKind.OnDataChangeNotification` / `AlarmEvent` / `RuntimeStatusChange` frames through the writer, swallows `ObjectDisposedException` for dispose race, unsubscribes on Dispose. `MxAccessGalaxyBackend.SubscribeAsync` wires `OnTagValueChanged` that fans values out per-tag to every subscription listening (one MXAccess subscription, multi-fan-out via `_refToSubs` reverse map). `UnsubscribeAsync` only calls `mx.UnsubscribeAsync` when the last sub for a tag drops. |
| Medium 4 | `WriteValuesAsync` doesn't await `OnWriteComplete` | `MxAccessClient.WriteAsync` rewritten to return `Task<bool>` via the v1-style TCS-keyed-by-item-handle pattern in `_pendingWrites`. TCS added before the `Write` call, awaited with configurable timeout (default 5s), removed in finally. Returns true only when `OnWriteComplete` reported success. `MxAccessGalaxyBackend.WriteValuesAsync` reports per-tag `Bad_InternalError` ("MXAccess runtime reported write failure") when the bool returns false. |
## Cross-cutting deferrals — resolved
| Deferral | Resolution |
|----------|------------|
| Deletion of v1 archive | Phase 3 PR 18 deleted the source trees; PR 61 closed `V1_ARCHIVE_STATUS.md` |
| Wonderware Historian SDK plugin port | `Driver.Galaxy.Host/Backend/Historian/` ports the 10 source files (`HistorianDataSource`, `HistorianClusterEndpointPicker`, `HistorianHealthSnapshot`, etc.). `MxAccessGalaxyBackend` implements `HistoryReadAsync` / `HistoryReadProcessedAsync` / `HistoryReadAtTimeAsync` / `HistoryReadEventsAsync`. `GalaxyProxyDriver.MapAggregateToColumn` translates `HistoryAggregateType``AnalogSummaryQuery` column names on the proxy side so Host stays OPC-UA-free. |
| MxAccess subscription push frames | Closed under Medium 3 above |
| Wonderware Historian-backed HistoryRead | Closed under the Historian port row |
| Alarm subsystem wire-up | PR 14. `GalaxyAlarmTracker` in `Backend/Alarms/` advises the four Galaxy alarm-state attributes per `IsAlarm=true` attribute (`.InAlarm`, `.Priority`, `.DescAttrName`, `.Acked`), runs the OPC UA Part 9 lifecycle simplified for the Galaxy AlarmExtension model, raises `AlarmTransition` events (Active / Acknowledged / Inactive) forwarded through the existing `OnAlarmEvent` IPC frame. `AcknowledgeAlarmAsync` writes operator comment to `<tag>.AckMsg` through the PR 4 TCS-by-handle write path. |
| Reconnect-without-recycle in MxAccessClient | Closed under High 2 (reconnect-with-replay loop is the "without-recycle" path — supervisor recycle remains the fallback). |
| Real downstream-consumer cutover | Out of scope for this repo; phased Year-3 rollout per `docs/v2/plan.md` §Rollout — not a Phase 2 deliverable. |
## 2026-04-20 test baseline
Full-solution `dotnet test ZB.MOM.WW.OtOpcUa.slnx` on `v2` tip:
| Project | Pass | Skip | Target |
|---|---:|---:|---|
| Core.Abstractions.Tests | 37 | 0 | net10 |
| Client.Shared.Tests | 136 | 0 | net10 |
| Client.CLI.Tests | 52 | 0 | net10 |
| Client.UI.Tests | 98 | 0 | net10 |
| Driver.S7.Tests | 58 | 0 | net10 |
| Driver.Modbus.Tests | 182 | 0 | net10 |
| Driver.Modbus.IntegrationTests | 2 | 21 | net10 (Docker-gated) |
| Driver.AbLegacy.Tests | 96 | 0 | net10 |
| Driver.AbCip.Tests | 211 | 0 | net10 |
| Driver.AbCip.IntegrationTests | 11 | 1 | net10 (ab_server-gated) |
| Driver.TwinCAT.Tests | 110 | 0 | net10 |
| Driver.OpcUaClient.Tests | 78 | 0 | net10 |
| Driver.FOCAS.Tests | 119 | 0 | net10 |
| Driver.Galaxy.Shared.Tests | 6 | 0 | net10 |
| Driver.Galaxy.Proxy.Tests | 18 | 7 | net10 (live-Galaxy-gated) |
| **Driver.Galaxy.Host.Tests** | **107** | **0** | **net48 x86** |
| Analyzers.Tests | 5 | 0 | net10 |
| Core.Tests | 182 | 0 | net10 |
| Configuration.Tests | 71 | 0 | net10 |
| Admin.Tests | 92 | 0 | net10 |
| Server.Tests | 173 | 0 | net10 |
| **Total** | **1844** | **29** | |
**Observed flake**: one Configuration.Tests failure on the first full-solution run turned
green on re-run. Not a stable regression; logged as a known flake until it reproduces.
**Skips are all infra-gated**:
- Modbus 21 skips — oitc/modbus-server Docker container not started.
- AbCip 1 skip — libplctag `ab_server` binary not on PATH.
- Galaxy.Proxy 7 skips — live Galaxy stack not reachable from the current shell (admin-token pipe ACL).
## What "Phase 2 closed" means for Phase 3 and later
- Galaxy runs as first-class v2 driver, same capability-interface contract as Modbus / S7 /
AbCip / AbLegacy / TwinCAT / FOCAS / OpcUaClient.
- No v1 code path remains. Anything invoking the `ZB.MOM.WW.LmxOpcUa.*` namespaces is
historical; any future work routes through `Driver.Galaxy.Proxy` + the named-pipe IPC.
- The 2026-04-13 stability findings live on as named regression tests under
`tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/StabilityFindingsRegressionTests.cs` — a
future refactor that reintroduces any of those four defects trips the test.
- Aveva Historian integration is wired end-to-end; new driver families don't need
Historian-specific plumbing in the IPC — they just implement `IHistoryProvider`.
## Outstanding — not Phase 2 blockers
- **AB CIP whole-UDT read optimization** (task #194) — niche performance win for large UDT
reads; current per-member fan-out works correctly.
- **AB CIP `IAlarmSource` via tag-projected ALMA/ALMD** (task #177) — AB CIP driver doesn't
currently expose alarms; feature-flagged follow-up.
- **IdentificationFolderBuilder wire-in** (task #195) — blocked on Equipment node walker.
- **UnsTab Playwright E2E** (task #199) — infra setup PR.
None of these are Phase 2 scope; all are tracked independently.

View File

@@ -1,5 +1,11 @@
# Phase 2 Final Exit Gate (2026-04-18)
> **⚠️ Superseded by [`exit-gate-phase-2-closed.md`](exit-gate-phase-2-closed.md) (2026-04-20).**
> This doc captures the snapshot at PR 2 merge — when the four `High` + `Medium` findings
> in the adversarial review were still OPEN and Historian port + alarm subsystem were still
> deferred. All of those closed subsequently (PR 4 + PR 12 + PR 13 + PR 14 + PR 61). Kept
> as historical evidence; consult the close-out doc for current Phase 2 status.
> Supersedes `phase-2-partial-exit-evidence.md` and `exit-gate-phase-2.md`. Captures the
> as-built state at the close of Phase 2 work delivered across two PRs.

View File

@@ -736,7 +736,7 @@ Each step leaves the system runnable. The generic extraction is effectively free
6. **Wire `Server`** — bootstrap from Configuration using an instance-bound credential (cert/gMSA/SQL login), fail fast if the credential is rejected, register drivers, start Core.
7. **Scaffold `Admin`** — Blazor Server app with: instance + credential management, draft/publish/rollback generation workflow (diff viewer, "publish to fleet", per-instance override), and core CRUD for drivers/devices/tags. Driver-specific config screens deferred to later phases.
**Phase 2 — Galaxy driver (prove the refactor)**
**Phase 2 — Galaxy driver (prove the refactor) — ✅ CLOSED 2026-04-20** (see [`implementation/exit-gate-phase-2-closed.md`](implementation/exit-gate-phase-2-closed.md))
8. **Build `Galaxy.Shared`** — .NET Standard 2.0 IPC message contracts
9. **Build `Galaxy.Host`** — .NET 4.8 x86 process hosting MxAccessBridge, GalaxyRepository, alarms, HDA with IPC server
10. **Build `Galaxy.Proxy`** — .NET 10 in-process proxy implementing IDriver interfaces, forwarding over IPC

View File

@@ -9,7 +9,7 @@ return await new CliApplicationBuilder()
if (type.IsSubclassOf(typeof(CommandBase))) return Activator.CreateInstance(type, CommandBase.DefaultFactory)!;
return Activator.CreateInstance(type)!;
})
.SetExecutableName("lmxopcua-cli")
.SetDescription("LmxOpcUa CLI - command-line client for the LmxOpcUa OPC UA server")
.SetExecutableName("otopcua-cli")
.SetDescription("OtOpcUa CLI - command-line client for the OtOpcUa OPC UA server")
.Build()
.RunAsync(args);

View File

@@ -18,8 +18,8 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi
var config = new ApplicationConfiguration
{
ApplicationName = "LmxOpcUaClient",
ApplicationUri = "urn:localhost:LmxOpcUaClient",
ApplicationName = "OtOpcUaClient",
ApplicationUri = "urn:localhost:OtOpcUaClient",
ApplicationType = ApplicationType.Client,
SecurityConfiguration = new SecurityConfiguration
{
@@ -60,7 +60,7 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi
{
var app = new ApplicationInstance
{
ApplicationName = "LmxOpcUaClient",
ApplicationName = "OtOpcUaClient",
ApplicationType = ApplicationType.Client,
ApplicationConfiguration = config
};

View File

@@ -0,0 +1,90 @@
namespace ZB.MOM.WW.OtOpcUa.Client.Shared;
/// <summary>
/// Resolves the canonical under-LocalAppData folder for the shared OPC UA client's PKI
/// store + persisted settings. Renamed from <c>LmxOpcUaClient</c> to <c>OtOpcUaClient</c>
/// in task #208; a one-shot migration shim moves a pre-rename folder in place on first
/// resolution so existing developer boxes keep their trusted server certs + saved
/// connection settings on upgrade.
/// </summary>
/// <remarks>
/// Thread-safe: the rename uses <see cref="Directory.Move"/> which is atomic on NTFS
/// within the same volume. The lock guarantees the migration runs at most once per
/// process even under concurrent first-touch from CLI + UI.
/// </remarks>
public static class ClientStoragePaths
{
/// <summary>Canonical client folder name. Post-#208.</summary>
public const string CanonicalFolderName = "OtOpcUaClient";
/// <summary>Pre-#208 folder name. Used only by the migration shim.</summary>
public const string LegacyFolderName = "LmxOpcUaClient";
private static readonly Lock _migrationLock = new();
private static bool _migrationChecked;
/// <summary>
/// Absolute path to the client's top-level folder under LocalApplicationData. Runs the
/// one-shot legacy-folder migration before returning so callers that depend on this
/// path (PKI store, settings file) find their existing state at the canonical name.
/// </summary>
public static string GetRoot()
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var canonical = Path.Combine(localAppData, CanonicalFolderName);
MigrateLegacyFolderIfNeeded(localAppData, canonical);
return canonical;
}
/// <summary>Subfolder for the application's PKI store — used by both CLI + UI.</summary>
public static string GetPkiPath() => Path.Combine(GetRoot(), "pki");
/// <summary>
/// Expose the migration probe for tests + for callers that want to check whether the
/// legacy folder still exists without forcing the rename. Returns true when a legacy
/// folder existed + was moved to canonical, false when no migration was needed or
/// canonical was already present.
/// </summary>
public static bool TryRunLegacyMigration()
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var canonical = Path.Combine(localAppData, CanonicalFolderName);
return MigrateLegacyFolderIfNeeded(localAppData, canonical);
}
private static bool MigrateLegacyFolderIfNeeded(string localAppData, string canonical)
{
// Fast-path out of the lock when the migration has already been attempted this process
// — saves the IO on every subsequent call, + the migration is idempotent within the
// same process anyway.
if (_migrationChecked) return false;
lock (_migrationLock)
{
if (_migrationChecked) return false;
_migrationChecked = true;
var legacy = Path.Combine(localAppData, LegacyFolderName);
// Only migrate when the legacy folder is present + canonical isn't. Either of the
// other three combinations (neither / only-canonical / both) means migration
// should NOT run: no-op fresh install, already-migrated, or manual state the
// developer has set up — don't clobber.
if (!Directory.Exists(legacy)) return false;
if (Directory.Exists(canonical)) return false;
try
{
Directory.Move(legacy, canonical);
return true;
}
catch (IOException)
{
// Concurrent another-process-moved-it or volume-boundary or permissions — leave
// the legacy folder alone; callers that need it can either re-run migration
// manually or point CertificateStorePath explicitly.
return false;
}
}
}
}

View File

@@ -41,11 +41,11 @@ public sealed class ConnectionSettings
public bool AutoAcceptCertificates { get; set; } = true;
/// <summary>
/// Path to the certificate store. Defaults to a subdirectory under LocalApplicationData.
/// Path to the certificate store. Defaults to a subdirectory under LocalApplicationData
/// resolved via <see cref="ClientStoragePaths"/> so the one-shot legacy-folder migration
/// runs before the path is returned.
/// </summary>
public string CertificateStorePath { get; set; } = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LmxOpcUaClient", "pki");
public string CertificateStorePath { get; set; } = ClientStoragePaths.GetPkiPath();
/// <summary>
/// Validates the settings and throws if any required values are missing or invalid.

View File

@@ -425,7 +425,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
: new UserIdentity();
var sessionTimeoutMs = (uint)(settings.SessionTimeoutSeconds * 1000);
return await _sessionFactory.CreateSessionAsync(config, endpoint, "LmxOpcUaClient", sessionTimeoutMs, identity,
return await _sessionFactory.CreateSessionAsync(config, endpoint, "OtOpcUaClient", sessionTimeoutMs, identity,
ct);
}

View File

@@ -1,4 +1,5 @@
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Client.Shared;
namespace ZB.MOM.WW.OtOpcUa.Client.UI.Services;
@@ -7,9 +8,9 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI.Services;
/// </summary>
public sealed class JsonSettingsService : ISettingsService
{
private static readonly string SettingsDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LmxOpcUaClient");
// ClientStoragePaths.GetRoot runs the one-shot legacy-folder migration so pre-#208
// developer boxes pick up their existing settings.json on first launch post-rename.
private static readonly string SettingsDir = ClientStoragePaths.GetRoot();
private static readonly string SettingsPath = Path.Combine(SettingsDir, "settings.json");

View File

@@ -21,9 +21,7 @@ public partial class MainWindowViewModel : ObservableObject
[ObservableProperty] private bool _autoAcceptCertificates = true;
[ObservableProperty] private string _certificateStorePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LmxOpcUaClient", "pki");
[ObservableProperty] private string _certificateStorePath = ClientStoragePaths.GetPkiPath();
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ConnectCommand))]

View File

@@ -0,0 +1,173 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.OpcUa;
/// <summary>
/// Materializes the canonical Unified Namespace browse tree for an Equipment-kind
/// <see cref="Configuration.Entities.Namespace"/> from the Config DB's
/// <c>UnsArea</c> / <c>UnsLine</c> / <c>Equipment</c> / <c>Tag</c> rows. Runs during
/// address-space build per <see cref="IDriver"/> whose
/// <c>Namespace.Kind = Equipment</c>; SystemPlatform-kind namespaces (Galaxy) are
/// exempt per decision #120 and reach this walker only indirectly through
/// <see cref="ITagDiscovery.DiscoverAsync"/>.
/// </summary>
/// <remarks>
/// <para>
/// <b>Composition strategy.</b> ADR-001 (2026-04-20) accepted Option A — Config
/// primary. The walker treats the supplied <see cref="EquipmentNamespaceContent"/>
/// snapshot as the authoritative published surface. Every Equipment row becomes a
/// folder node at the UNS level-5 segment; every <see cref="Tag"/> bound to an
/// Equipment (non-null <see cref="Tag.EquipmentId"/>) becomes a variable node under
/// it. Driver-discovered tags that have no Config-DB row are not added by this
/// walker — the ITagDiscovery path continues to exist for the SystemPlatform case +
/// for enrichment, but Equipment-kind composition is fully Tag-row-driven.
/// </para>
///
/// <para>
/// <b>Under each Equipment node.</b> Five identifier properties per decision #121
/// (<c>EquipmentId</c>, <c>EquipmentUuid</c>, <c>MachineCode</c>, <c>ZTag</c>,
/// <c>SAPID</c>) are added as OPC UA properties — external systems (ERP, SAP PM)
/// resolve equipment by whichever identifier they natively use without a sidecar.
/// <see cref="IdentificationFolderBuilder.Build"/> materializes the OPC 40010
/// Identification sub-folder with the nine decision-#139 fields when at least one
/// is non-null; when all nine are null the sub-folder is omitted rather than
/// appearing empty.
/// </para>
///
/// <para>
/// <b>Address resolution.</b> Variable nodes carry the driver-side full reference
/// in <see cref="DriverAttributeInfo.FullName"/> copied from <c>Tag.TagConfig</c>
/// (the wire-level address JSON blob whose interpretation is driver-specific). At
/// runtime the dispatch layer routes Read/Write calls through the configured
/// capability invoker; an unreachable address surfaces as an OPC UA Bad status via
/// the natural driver-read failure path, NOT as a build-time reject. The ADR calls
/// this "BadNotFound placeholder" behavior — legible to operators via their Admin
/// UI + OPC UA client inspection of node status.
/// </para>
///
/// <para>
/// <b>Pure function.</b> This class has no dependency on the OPC UA SDK, no
/// Config-DB access, no state. It consumes pre-loaded EF Core rows + streams calls
/// into the supplied <see cref="IAddressSpaceBuilder"/>. The server-side wiring
/// (load snapshot → invoke walker → per-tag capability probe) lives in the Task B
/// PR alongside <c>NodeScopeResolver</c>'s Config-DB join.
/// </para>
/// </remarks>
public static class EquipmentNodeWalker
{
/// <summary>
/// Walk <paramref name="content"/> into <paramref name="namespaceBuilder"/>.
/// The builder is scoped to the Equipment-kind namespace root; the walker emits
/// Area → Line → Equipment folders under it, then identifier properties + the
/// Identification sub-folder + variable nodes per bound Tag under each Equipment.
/// </summary>
/// <param name="namespaceBuilder">
/// The builder scoped to the Equipment-kind namespace root. Caller is responsible for
/// creating this (e.g. <c>rootBuilder.Folder(namespace.NamespaceId, namespace.NamespaceUri)</c>).
/// </param>
/// <param name="content">Pre-loaded + pre-filtered rows for a single published generation.</param>
public static void Walk(IAddressSpaceBuilder namespaceBuilder, EquipmentNamespaceContent content)
{
ArgumentNullException.ThrowIfNull(namespaceBuilder);
ArgumentNullException.ThrowIfNull(content);
// Group lines by area + equipment by line + tags by equipment up-front. Avoids an
// O(N·M) re-scan at each UNS level on large fleets.
var linesByArea = content.Lines
.GroupBy(l => l.UnsAreaId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.OrderBy(l => l.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
var equipmentByLine = content.Equipment
.GroupBy(e => e.UnsLineId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.OrderBy(e => e.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
var tagsByEquipment = content.Tags
.Where(t => !string.IsNullOrEmpty(t.EquipmentId))
.GroupBy(t => t.EquipmentId!, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.OrderBy(t => t.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
foreach (var area in content.Areas.OrderBy(a => a.Name, StringComparer.Ordinal))
{
var areaBuilder = namespaceBuilder.Folder(area.Name, area.Name);
if (!linesByArea.TryGetValue(area.UnsAreaId, out var areaLines)) continue;
foreach (var line in areaLines)
{
var lineBuilder = areaBuilder.Folder(line.Name, line.Name);
if (!equipmentByLine.TryGetValue(line.UnsLineId, out var lineEquipment)) continue;
foreach (var equipment in lineEquipment)
{
var equipmentBuilder = lineBuilder.Folder(equipment.Name, equipment.Name);
AddIdentifierProperties(equipmentBuilder, equipment);
IdentificationFolderBuilder.Build(equipmentBuilder, equipment);
if (!tagsByEquipment.TryGetValue(equipment.EquipmentId, out var equipmentTags)) continue;
foreach (var tag in equipmentTags)
AddTagVariable(equipmentBuilder, tag);
}
}
}
}
/// <summary>
/// Adds the five operator-facing identifiers from decision #121 as OPC UA properties
/// on the Equipment node. EquipmentId + EquipmentUuid are always populated;
/// MachineCode is required per <see cref="Equipment"/>; ZTag + SAPID are nullable in
/// the data model so they're skipped when null to avoid empty-string noise in the
/// browse tree.
/// </summary>
private static void AddIdentifierProperties(IAddressSpaceBuilder equipmentBuilder, Equipment equipment)
{
equipmentBuilder.AddProperty("EquipmentId", DriverDataType.String, equipment.EquipmentId);
equipmentBuilder.AddProperty("EquipmentUuid", DriverDataType.String, equipment.EquipmentUuid.ToString());
equipmentBuilder.AddProperty("MachineCode", DriverDataType.String, equipment.MachineCode);
if (!string.IsNullOrEmpty(equipment.ZTag))
equipmentBuilder.AddProperty("ZTag", DriverDataType.String, equipment.ZTag);
if (!string.IsNullOrEmpty(equipment.SAPID))
equipmentBuilder.AddProperty("SAPID", DriverDataType.String, equipment.SAPID);
}
/// <summary>
/// Emit a single Tag row as an <see cref="IAddressSpaceBuilder.Variable"/>. The driver
/// full reference lives in <c>Tag.TagConfig</c> (wire-level address, driver-specific
/// JSON blob); the variable node's data type derives from <c>Tag.DataType</c>.
/// Unreachable-address behavior per ADR-001 Option A: the variable is created; the
/// driver's natural Read failure surfaces an OPC UA Bad status at runtime.
/// </summary>
private static void AddTagVariable(IAddressSpaceBuilder equipmentBuilder, Tag tag)
{
var attr = new DriverAttributeInfo(
FullName: tag.TagConfig,
DriverDataType: ParseDriverDataType(tag.DataType),
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.FreeAccess,
IsHistorized: false);
equipmentBuilder.Variable(tag.Name, tag.Name, attr);
}
/// <summary>
/// Parse <see cref="Tag.DataType"/> (stored as the <see cref="DriverDataType"/> enum
/// name string, decision #138) into the enum value. Unknown names fall back to
/// <see cref="DriverDataType.String"/> so a one-off driver-specific type doesn't
/// abort the whole walk; the underlying driver still sees the original TagConfig
/// address + can surface its own typed value via the OPC UA variant at read time.
/// </summary>
private static DriverDataType ParseDriverDataType(string raw) =>
Enum.TryParse<DriverDataType>(raw, ignoreCase: true, out var parsed) ? parsed : DriverDataType.String;
}
/// <summary>
/// Pre-loaded + pre-filtered snapshot of one Equipment-kind namespace's worth of Config
/// DB rows. All four collections are scoped to the same
/// <see cref="Configuration.Entities.ConfigGeneration"/> + the same
/// <see cref="Configuration.Entities.Namespace"/> row. The walker assumes this filter
/// was applied by the caller + does no cross-generation or cross-namespace validation.
/// </summary>
public sealed record EquipmentNamespaceContent(
IReadOnlyList<UnsArea> Areas,
IReadOnlyList<UnsLine> Lines,
IReadOnlyList<Equipment> Equipment,
IReadOnlyList<Tag> Tags);

View File

@@ -0,0 +1,232 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Task #177 — projects AB Logix ALMD alarm instructions onto the OPC UA alarm surface by
/// polling the ALMD UDT's <c>InFaulted</c> / <c>Acked</c> / <c>Severity</c> members at a
/// configurable interval + translating state transitions into <c>OnAlarmEvent</c>
/// callbacks on the owning <see cref="AbCipDriver"/>. Feature-flagged off by default via
/// <see cref="AbCipDriverOptions.EnableAlarmProjection"/>; callers that leave the flag off
/// get a no-op subscribe path so capability negotiation still works.
/// </summary>
/// <remarks>
/// <para>ALMD-only in this pass. ALMA (analog alarm) projection is a follow-up because
/// its threshold + limit semantics need more design — ALMD's "is the alarm active + has
/// the operator acked" shape maps cleanly onto the driver-agnostic
/// <see cref="IAlarmSource"/> contract without concessions.</para>
///
/// <para>Polling reuses <see cref="AbCipDriver.ReadAsync"/>, so ALMD reads get the #194
/// whole-UDT optimization for free when the ALMD is declared with its standard members.
/// One poll loop per subscription call; the loop batches every
/// member read across the full source-node set into a single ReadAsync per tick.</para>
///
/// <para>ALMD <c>Acked</c> write semantics on Logix are rising-edge sensitive at the
/// instruction level — writing <c>Acked=1</c> directly is honored by FT View + the
/// standard HMI templates, but some PLC programs read <c>AckCmd</c> + look for the edge
/// themselves. We pick the simpler <c>Acked</c> write for first pass; operators whose
/// ladder watches <c>AckCmd</c> can wire a follow-up "AckCmd 0→1→0" pulse on the client
/// side until a driver-level knob lands.</para>
/// </remarks>
internal sealed class AbCipAlarmProjection : IAsyncDisposable
{
private readonly AbCipDriver _driver;
private readonly TimeSpan _pollInterval;
private readonly Dictionary<long, Subscription> _subs = new();
private readonly Lock _subsLock = new();
private long _nextId;
public AbCipAlarmProjection(AbCipDriver driver, TimeSpan pollInterval)
{
_driver = driver;
_pollInterval = pollInterval;
}
public async Task<IAlarmSubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
{
var id = Interlocked.Increment(ref _nextId);
var handle = new AbCipAlarmSubscriptionHandle(id);
var cts = new CancellationTokenSource();
var sub = new Subscription(handle, [..sourceNodeIds], cts);
lock (_subsLock) _subs[id] = sub;
sub.Loop = Task.Run(() => RunPollLoopAsync(sub, cts.Token), cts.Token);
await Task.CompletedTask;
return handle;
}
public async Task UnsubscribeAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
{
if (handle is not AbCipAlarmSubscriptionHandle h) return;
Subscription? sub;
lock (_subsLock)
{
if (!_subs.Remove(h.Id, out sub)) return;
}
try { sub.Cts.Cancel(); } catch { }
try { await sub.Loop.ConfigureAwait(false); } catch { }
sub.Cts.Dispose();
}
public async Task AcknowledgeAsync(
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
{
if (acknowledgements.Count == 0) return;
// Write Acked=1 per request. IWritable isn't on AbCipAlarmProjection so route through
// the driver's public interface — delegating instead of re-implementing the write path
// keeps the bit-in-DINT + idempotency + per-call-host-resolve knobs intact.
var requests = acknowledgements
.Select(a => new WriteRequest($"{a.SourceNodeId}.Acked", true))
.ToArray();
// Best-effort — the driver's WriteAsync returns per-item status; individual ack
// failures don't poison the batch. Swallow the return so a single faulted ack
// doesn't bubble out of the caller's batch expectation.
_ = await _driver.WriteAsync(requests, cancellationToken).ConfigureAwait(false);
}
public async ValueTask DisposeAsync()
{
List<Subscription> snap;
lock (_subsLock) { snap = _subs.Values.ToList(); _subs.Clear(); }
foreach (var sub in snap)
{
try { sub.Cts.Cancel(); } catch { }
try { await sub.Loop.ConfigureAwait(false); } catch { }
sub.Cts.Dispose();
}
}
/// <summary>
/// Poll-tick body — reads <c>InFaulted</c> + <c>Severity</c> for every source node id
/// in the subscription, diffs each against last-seen state, fires raise/clear events.
/// Extracted so tests can drive one tick without standing up the Task.Run loop.
/// </summary>
internal void Tick(Subscription sub, IReadOnlyList<DataValueSnapshot> results)
{
// results index layout: for each sourceNode, [InFaulted, Severity] in order.
for (var i = 0; i < sub.SourceNodeIds.Count; i++)
{
var nodeId = sub.SourceNodeIds[i];
var inFaultedDv = results[i * 2];
var severityDv = results[i * 2 + 1];
if (inFaultedDv.StatusCode != AbCipStatusMapper.Good) continue;
var nowFaulted = ToBool(inFaultedDv.Value);
var severity = ToInt(severityDv.Value);
var wasFaulted = sub.LastInFaulted.GetValueOrDefault(nodeId, false);
sub.LastInFaulted[nodeId] = nowFaulted;
if (!wasFaulted && nowFaulted)
{
_driver.InvokeAlarmEvent(new AlarmEventArgs(
sub.Handle, nodeId, ConditionId: $"{nodeId}#active",
AlarmType: "ALMD",
Message: $"ALMD {nodeId} raised",
Severity: MapSeverity(severity),
SourceTimestampUtc: DateTime.UtcNow));
}
else if (wasFaulted && !nowFaulted)
{
_driver.InvokeAlarmEvent(new AlarmEventArgs(
sub.Handle, nodeId, ConditionId: $"{nodeId}#active",
AlarmType: "ALMD",
Message: $"ALMD {nodeId} cleared",
Severity: MapSeverity(severity),
SourceTimestampUtc: DateTime.UtcNow));
}
}
}
private async Task RunPollLoopAsync(Subscription sub, CancellationToken ct)
{
var refs = new List<string>(sub.SourceNodeIds.Count * 2);
foreach (var nodeId in sub.SourceNodeIds)
{
refs.Add($"{nodeId}.InFaulted");
refs.Add($"{nodeId}.Severity");
}
while (!ct.IsCancellationRequested)
{
try
{
var results = await _driver.ReadAsync(refs, ct).ConfigureAwait(false);
Tick(sub, results);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
catch { /* per-tick failures are non-fatal; next tick retries */ }
try { await Task.Delay(_pollInterval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }
}
}
internal static AlarmSeverity MapSeverity(int raw) => raw switch
{
<= 250 => AlarmSeverity.Low,
<= 500 => AlarmSeverity.Medium,
<= 750 => AlarmSeverity.High,
_ => AlarmSeverity.Critical,
};
private static bool ToBool(object? v) => v switch
{
bool b => b,
int i => i != 0,
long l => l != 0,
_ => false,
};
private static int ToInt(object? v) => v switch
{
int i => i,
long l => (int)l,
short s => s,
byte b => b,
_ => 0,
};
internal sealed class Subscription
{
public Subscription(AbCipAlarmSubscriptionHandle handle, IReadOnlyList<string> sourceNodeIds, CancellationTokenSource cts)
{
Handle = handle; SourceNodeIds = sourceNodeIds; Cts = cts;
}
public AbCipAlarmSubscriptionHandle Handle { get; }
public IReadOnlyList<string> SourceNodeIds { get; }
public CancellationTokenSource Cts { get; }
public Task Loop { get; set; } = Task.CompletedTask;
public Dictionary<string, bool> LastInFaulted { get; } = new(StringComparer.Ordinal);
}
}
/// <summary>Handle returned by <see cref="AbCipAlarmProjection.SubscribeAsync"/>.</summary>
public sealed record AbCipAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle
{
public string DiagnosticId => $"abcip-alarm-sub-{Id}";
}
/// <summary>
/// Detects the ALMD / ALMA signature in an <see cref="AbCipTagDefinition"/>'s declared
/// members. Used by both discovery (to stamp <c>IsAlarm=true</c> on the emitted
/// variable) + initial driver setup (to decide which tags the alarm projection owns).
/// </summary>
public static class AbCipAlarmDetector
{
/// <summary>
/// <c>true</c> when <paramref name="tag"/> is a Structure whose declared members match
/// the ALMD signature (<c>InFaulted</c> + <c>Acked</c> present). ALMA detection
/// (analog alarms with <c>HHLimit</c>/<c>HLimit</c>/<c>LLimit</c>/<c>LLLimit</c>)
/// ships as a follow-up.
/// </summary>
public static bool IsAlmd(AbCipTagDefinition tag)
{
if (tag.DataType != AbCipDataType.Structure || tag.Members is null) return false;
var names = tag.Members.Select(m => m.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
return names.Contains("InFaulted") && names.Contains("Acked");
}
}

View File

@@ -21,7 +21,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <see cref="PlcTagHandle"/> and reconnects each device.</para>
/// </remarks>
public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable
{
private readonly AbCipDriverOptions _options;
private readonly string _driverInstanceId;
@@ -32,10 +32,15 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private readonly PollGroupEngine _poll;
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, AbCipTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
private readonly AbCipAlarmProjection _alarmProjection;
private DriverHealth _health = new(DriverState.Unknown, null, null);
public event EventHandler<DataChangeEventArgs>? OnDataChange;
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
/// <summary>Internal seam for the alarm projection to raise events through the driver.</summary>
internal void InvokeAlarmEvent(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args);
public AbCipDriver(AbCipDriverOptions options, string driverInstanceId,
IAbCipTagFactory? tagFactory = null,
@@ -52,6 +57,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
_alarmProjection = new AbCipAlarmProjection(this, _options.AlarmPollInterval);
}
/// <summary>
@@ -162,6 +168,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
await _alarmProjection.DisposeAsync().ConfigureAwait(false);
await _poll.DisposeAsync().ConfigureAwait(false);
foreach (var state in _devices.Values)
{
@@ -187,6 +194,39 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
return Task.CompletedTask;
}
// ---- IAlarmSource (ALMD projection, #177) ----
/// <summary>
/// Subscribe to ALMD alarm transitions on <paramref name="sourceNodeIds"/>. Each id
/// names a declared ALMD UDT tag; the projection polls the tag's <c>InFaulted</c> +
/// <c>Severity</c> members at <see cref="AbCipDriverOptions.AlarmPollInterval"/> and
/// fires <see cref="OnAlarmEvent"/> on 0→1 (raise) + 1→0 (clear) transitions.
/// Feature-gated — when <see cref="AbCipDriverOptions.EnableAlarmProjection"/> is
/// <c>false</c> (the default), returns a handle wrapping a no-op subscription so
/// capability negotiation still works; <see cref="OnAlarmEvent"/> never fires.
/// </summary>
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
{
if (!_options.EnableAlarmProjection)
{
var disabled = new AbCipAlarmSubscriptionHandle(0);
return Task.FromResult<IAlarmSubscriptionHandle>(disabled);
}
return _alarmProjection.SubscribeAsync(sourceNodeIds, cancellationToken);
}
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) =>
_options.EnableAlarmProjection
? _alarmProjection.UnsubscribeAsync(handle, cancellationToken)
: Task.CompletedTask;
public Task AcknowledgeAsync(
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
_options.EnableAlarmProjection
? _alarmProjection.AcknowledgeAsync(acknowledgements, cancellationToken)
: Task.CompletedTask;
// ---- IHostConnectivityProbe ----
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() =>
@@ -287,39 +327,55 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var now = DateTime.UtcNow;
var results = new DataValueSnapshot[fullReferences.Count];
for (var i = 0; i < fullReferences.Count; i++)
// Task #194 — plan the batch: members of the same parent UDT get collapsed into one
// whole-UDT read + in-memory member decode; every other reference falls back to the
// per-tag path that's been here since PR 3. Planner is a pure function over the
// current tag map; BOOL/String/Structure members stay on the fallback path because
// declaration-only offsets can't place them under Logix alignment rules.
var plan = AbCipUdtReadPlanner.Build(fullReferences, _tagsByName);
foreach (var group in plan.Groups)
await ReadGroupAsync(group, results, now, cancellationToken).ConfigureAwait(false);
foreach (var fb in plan.Fallbacks)
await ReadSingleAsync(fb, fullReferences[fb.OriginalIndex], results, now, cancellationToken).ConfigureAwait(false);
return results;
}
private async Task ReadSingleAsync(
AbCipUdtReadFallback fb, string reference, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
{
var reference = fullReferences[i];
if (!_tagsByName.TryGetValue(reference, out var def))
{
results[i] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
continue;
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
return;
}
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
{
results[i] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
continue;
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
return;
}
try
{
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
await runtime.ReadAsync(cancellationToken).ConfigureAwait(false);
var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
await runtime.ReadAsync(ct).ConfigureAwait(false);
var status = runtime.GetStatus();
if (status != 0)
{
results[i] = new DataValueSnapshot(null,
results[fb.OriginalIndex] = new DataValueSnapshot(null,
AbCipStatusMapper.MapLibplctagStatus(status), null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"libplctag status {status} reading {reference}");
continue;
return;
}
var tagPath = AbCipTagPath.TryParse(def.TagPath);
var bitIndex = tagPath?.BitIndex;
var value = runtime.DecodeValue(def.DataType, bitIndex);
results[i] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
results[fb.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
_health = new DriverHealth(DriverState.Healthy, now, null);
}
catch (OperationCanceledException)
@@ -328,13 +384,68 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
catch (Exception ex)
{
results[i] = new DataValueSnapshot(null,
results[fb.OriginalIndex] = new DataValueSnapshot(null,
AbCipStatusMapper.BadCommunicationError, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
}
}
return results;
/// <summary>
/// Task #194 — perform one whole-UDT read on the parent tag, then decode each
/// grouped member from the runtime's buffer at its computed byte offset. A per-group
/// failure (parent read raised, non-zero libplctag status, or missing device) stamps
/// the mapped fault across every grouped member only — sibling groups + the
/// per-tag fallback list are unaffected.
/// </summary>
private async Task ReadGroupAsync(
AbCipUdtReadGroup group, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
{
var parent = group.ParentDefinition;
if (!_devices.TryGetValue(parent.DeviceHostAddress, out var device))
{
StampGroupStatus(group, results, now, AbCipStatusMapper.BadNodeIdUnknown);
return;
}
try
{
var runtime = await EnsureTagRuntimeAsync(device, parent, ct).ConfigureAwait(false);
await runtime.ReadAsync(ct).ConfigureAwait(false);
var status = runtime.GetStatus();
if (status != 0)
{
var mapped = AbCipStatusMapper.MapLibplctagStatus(status);
StampGroupStatus(group, results, now, mapped);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"libplctag status {status} reading UDT {group.ParentName}");
return;
}
foreach (var member in group.Members)
{
var value = runtime.DecodeValueAt(member.Definition.DataType, member.Offset, bitIndex: null);
results[member.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
}
_health = new DriverHealth(DriverState.Healthy, now, null);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
StampGroupStatus(group, results, now, AbCipStatusMapper.BadCommunicationError);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
}
}
private static void StampGroupStatus(
AbCipUdtReadGroup group, DataValueSnapshot[] results, DateTime now, uint statusCode)
{
foreach (var member in group.Members)
results[member.OriginalIndex] = new DataValueSnapshot(null, statusCode, null, now);
}
// ---- IWritable ----

View File

@@ -38,6 +38,24 @@ public sealed class AbCipDriverOptions
/// should appear in the address space.
/// </summary>
public bool EnableControllerBrowse { get; init; }
/// <summary>
/// Task #177 — when <c>true</c>, declared ALMD tags are surfaced as alarm conditions
/// via <see cref="Core.Abstractions.IAlarmSource"/>; the driver polls each subscribed
/// alarm's <c>InFaulted</c> + <c>Severity</c> members + fires <c>OnAlarmEvent</c> on
/// state transitions. Default <c>false</c> — operators explicitly opt in because
/// projection semantics don't exactly mirror Rockwell FT Alarm &amp; Events; shops
/// running FT Live should keep this off + take alarms through the native route.
/// </summary>
public bool EnableAlarmProjection { get; init; }
/// <summary>
/// Poll interval for the ALMD projection loop. Shorter intervals catch faster edges
/// at the cost of PLC round-trips; edges shorter than this interval are invisible to
/// the projection (a 0→1→0 transition within one tick collapses to no event). Default
/// 1 second — matches typical SCADA alarm-refresh conventions.
/// </summary>
public TimeSpan AlarmPollInterval { get; init; } = TimeSpan.FromSeconds(1);
}
/// <summary>

View File

@@ -0,0 +1,78 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Computes byte offsets for declared UDT members under Logix natural-alignment rules so
/// a single whole-UDT read (task #194) can decode each member from one buffer without
/// re-reading per member. Declaration-driven — the caller supplies
/// <see cref="AbCipStructureMember"/> rows; this helper produces the offset each member
/// sits at in the parent tag's read buffer.
/// </summary>
/// <remarks>
/// <para>Alignment rules applied per Rockwell "Logix 5000 Data Access" manual + the
/// libplctag test fixtures: each member aligns to its natural boundary (SInt 1, Int 2,
/// DInt/Real/Dt 4, LInt/ULInt/LReal 8), padding inserted before the member as needed.
/// The total size is padded to the alignment of the largest member so arrays-of-UDT also
/// work at element stride — though this helper is used only on single instances today.</para>
///
/// <para><see cref="TryBuild"/> returns <c>null</c> on unsupported member types
/// (<see cref="AbCipDataType.Bool"/>, <see cref="AbCipDataType.String"/>,
/// <see cref="AbCipDataType.Structure"/>). Whole-UDT grouping opts out of those groups
/// and falls back to the per-tag read path — BOOL members are packed into a hidden host
/// byte at the top of the UDT under Logix, so their offset can't be computed from
/// declared-member order alone. The CIP Template Object reader produces a
/// <see cref="AbCipUdtShape"/> that carries real offsets for BOOL + nested structs; when
/// that shape is cached the driver can take the richer path instead.</para>
/// </remarks>
public static class AbCipUdtMemberLayout
{
/// <summary>
/// Try to compute member offsets for the supplied declared members. Returns <c>null</c>
/// if any member type is unsupported for declaration-only layout.
/// </summary>
public static IReadOnlyDictionary<string, int>? TryBuild(
IReadOnlyList<AbCipStructureMember> members)
{
ArgumentNullException.ThrowIfNull(members);
if (members.Count == 0) return null;
var offsets = new Dictionary<string, int>(members.Count, StringComparer.OrdinalIgnoreCase);
var cursor = 0;
foreach (var member in members)
{
if (!TryGetSizeAlign(member.DataType, out var size, out var align))
return null;
if (cursor % align != 0)
cursor += align - (cursor % align);
offsets[member.Name] = cursor;
cursor += size;
}
return offsets;
}
/// <summary>
/// Natural size + alignment for a Logix atomic type. <c>false</c> for types excluded
/// from declaration-only grouping (Bool / String / Structure).
/// </summary>
private static bool TryGetSizeAlign(AbCipDataType type, out int size, out int align)
{
switch (type)
{
case AbCipDataType.SInt: case AbCipDataType.USInt:
size = 1; align = 1; return true;
case AbCipDataType.Int: case AbCipDataType.UInt:
size = 2; align = 2; return true;
case AbCipDataType.DInt: case AbCipDataType.UDInt:
case AbCipDataType.Real: case AbCipDataType.Dt:
size = 4; align = 4; return true;
case AbCipDataType.LInt: case AbCipDataType.ULInt:
case AbCipDataType.LReal:
size = 8; align = 8; return true;
default:
size = 0; align = 0; return false;
}
}
}

View File

@@ -0,0 +1,109 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Task #194 — groups a ReadAsync batch of full-references into whole-UDT reads where
/// possible. A group is emitted for every parent UDT tag whose declared
/// <see cref="AbCipStructureMember"/>s produced a valid offset map AND at least two of
/// its members appear in the batch; every other reference stays in the per-tag fallback
/// list that <see cref="AbCipDriver.ReadAsync"/> runs through its existing read path.
/// Pure function — the planner never touches the runtime + never reads the PLC.
/// </summary>
public static class AbCipUdtReadPlanner
{
/// <summary>
/// Split <paramref name="requests"/> into whole-UDT groups + per-tag leftovers.
/// <paramref name="tagsByName"/> is the driver's <c>_tagsByName</c> map — both parent
/// UDT rows and their fanned-out member rows live there. Lookup is OrdinalIgnoreCase
/// to match the driver's dictionary semantics.
/// </summary>
public static AbCipUdtReadPlan Build(
IReadOnlyList<string> requests,
IReadOnlyDictionary<string, AbCipTagDefinition> tagsByName)
{
ArgumentNullException.ThrowIfNull(requests);
ArgumentNullException.ThrowIfNull(tagsByName);
var fallback = new List<AbCipUdtReadFallback>(requests.Count);
var byParent = new Dictionary<string, List<AbCipUdtReadMember>>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < requests.Count; i++)
{
var name = requests[i];
if (!tagsByName.TryGetValue(name, out var def))
{
fallback.Add(new AbCipUdtReadFallback(i, name));
continue;
}
var (parentName, memberName) = SplitParentMember(name);
if (parentName is null || memberName is null
|| !tagsByName.TryGetValue(parentName, out var parent)
|| parent.DataType != AbCipDataType.Structure
|| parent.Members is not { Count: > 0 })
{
fallback.Add(new AbCipUdtReadFallback(i, name));
continue;
}
var offsets = AbCipUdtMemberLayout.TryBuild(parent.Members);
if (offsets is null || !offsets.TryGetValue(memberName, out var offset))
{
fallback.Add(new AbCipUdtReadFallback(i, name));
continue;
}
if (!byParent.TryGetValue(parentName, out var members))
{
members = new List<AbCipUdtReadMember>();
byParent[parentName] = members;
}
members.Add(new AbCipUdtReadMember(i, def, offset));
}
// A single-member group saves nothing (one whole-UDT read replaces one per-member read)
// — demote to fallback to avoid paying the cost of reading the full UDT buffer only to
// pull one field out.
var groups = new List<AbCipUdtReadGroup>(byParent.Count);
foreach (var (parentName, members) in byParent)
{
if (members.Count < 2)
{
foreach (var m in members)
fallback.Add(new AbCipUdtReadFallback(m.OriginalIndex, m.Definition.Name));
continue;
}
groups.Add(new AbCipUdtReadGroup(parentName, tagsByName[parentName], members));
}
return new AbCipUdtReadPlan(groups, fallback);
}
private static (string? Parent, string? Member) SplitParentMember(string reference)
{
var dot = reference.IndexOf('.');
if (dot <= 0 || dot == reference.Length - 1) return (null, null);
return (reference[..dot], reference[(dot + 1)..]);
}
}
/// <summary>A planner output: grouped UDT reads + per-tag fallbacks.</summary>
public sealed record AbCipUdtReadPlan(
IReadOnlyList<AbCipUdtReadGroup> Groups,
IReadOnlyList<AbCipUdtReadFallback> Fallbacks);
/// <summary>One UDT parent whose members were batched into a single read.</summary>
public sealed record AbCipUdtReadGroup(
string ParentName,
AbCipTagDefinition ParentDefinition,
IReadOnlyList<AbCipUdtReadMember> Members);
/// <summary>
/// One member inside an <see cref="AbCipUdtReadGroup"/>. <c>OriginalIndex</c> is the
/// slot in the caller's request list so the decoded value lands at the correct output
/// offset. <c>Definition</c> is the fanned-out member-level tag definition. <c>Offset</c>
/// is the byte offset within the parent UDT buffer where this member lives.
/// </summary>
public sealed record AbCipUdtReadMember(int OriginalIndex, AbCipTagDefinition Definition, int Offset);
/// <summary>A reference that falls back to the per-tag read path.</summary>
public sealed record AbCipUdtReadFallback(int OriginalIndex, string Reference);

View File

@@ -31,6 +31,17 @@ public interface IAbCipTagRuntime : IDisposable
/// </summary>
object? DecodeValue(AbCipDataType type, int? bitIndex);
/// <summary>
/// Decode a value at an arbitrary byte offset in the local buffer. Task #194 —
/// whole-UDT reads perform one <see cref="ReadAsync"/> on the parent UDT tag then
/// call this per declared member with its computed offset, avoiding one libplctag
/// round-trip per member. Implementations that do not support offset-aware decoding
/// may fall back to <see cref="DecodeValue"/> when <paramref name="offset"/> is zero;
/// offsets greater than zero against an unsupporting runtime should return <c>null</c>
/// so the planner can skip grouping.
/// </summary>
object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex);
/// <summary>
/// Encode <paramref name="value"/> into the local buffer per the tag's type. Callers
/// pair this with <see cref="WriteAsync"/>.

View File

@@ -32,24 +32,26 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
public int GetStatus() => (int)_tag.GetStatus();
public object? DecodeValue(AbCipDataType type, int? bitIndex) => type switch
public object? DecodeValue(AbCipDataType type, int? bitIndex) => DecodeValueAt(type, 0, bitIndex);
public object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex) => type switch
{
AbCipDataType.Bool => bitIndex is int bit
? _tag.GetBit(bit)
: _tag.GetInt8(0) != 0,
AbCipDataType.SInt => (int)(sbyte)_tag.GetInt8(0),
AbCipDataType.USInt => (int)_tag.GetUInt8(0),
AbCipDataType.Int => (int)_tag.GetInt16(0),
AbCipDataType.UInt => (int)_tag.GetUInt16(0),
AbCipDataType.DInt => _tag.GetInt32(0),
AbCipDataType.UDInt => (int)_tag.GetUInt32(0),
AbCipDataType.LInt => _tag.GetInt64(0),
AbCipDataType.ULInt => (long)_tag.GetUInt64(0),
AbCipDataType.Real => _tag.GetFloat32(0),
AbCipDataType.LReal => _tag.GetFloat64(0),
AbCipDataType.String => _tag.GetString(0),
AbCipDataType.Dt => _tag.GetInt32(0), // seconds-since-epoch DINT; consumer widens as needed
AbCipDataType.Structure => null, // UDT whole-tag decode lands in PR 6
: _tag.GetInt8(offset) != 0,
AbCipDataType.SInt => (int)(sbyte)_tag.GetInt8(offset),
AbCipDataType.USInt => (int)_tag.GetUInt8(offset),
AbCipDataType.Int => (int)_tag.GetInt16(offset),
AbCipDataType.UInt => (int)_tag.GetUInt16(offset),
AbCipDataType.DInt => _tag.GetInt32(offset),
AbCipDataType.UDInt => (int)_tag.GetUInt32(offset),
AbCipDataType.LInt => _tag.GetInt64(offset),
AbCipDataType.ULInt => (long)_tag.GetUInt64(offset),
AbCipDataType.Real => _tag.GetFloat32(offset),
AbCipDataType.LReal => _tag.GetFloat64(offset),
AbCipDataType.String => _tag.GetString(offset),
AbCipDataType.Dt => _tag.GetInt32(offset),
AbCipDataType.Structure => null,
_ => null,
};

View File

@@ -0,0 +1,47 @@
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
/// <summary>
/// Holds pre-loaded <see cref="EquipmentNamespaceContent"/> snapshots keyed by
/// <c>DriverInstanceId</c>. Populated once during <see cref="OpcUaServerService"/> startup
/// (after <see cref="NodeBootstrap"/> resolves the generation) so the synchronous lookup
/// delegate on <see cref="OpcUaApplicationHost"/> can serve the walker from memory without
/// blocking on async DB I/O mid-dispatch.
/// </summary>
/// <remarks>
/// <para>The registry is intentionally a shared mutable singleton with set-once-per-bootstrap
/// semantics rather than an immutable map passed by value — the composition in Program.cs
/// builds <see cref="OpcUaApplicationHost"/> before <see cref="NodeBootstrap"/> runs, so the
/// registry must exist at DI-compose time but be empty until the generation is known. A
/// driver registered after the initial populate pass simply returns null from
/// <see cref="Get"/> + the wire-in falls back to the "no UNS content, let DiscoverAsync own
/// it" path that PR #155 established.</para>
/// </remarks>
public sealed class DriverEquipmentContentRegistry
{
private readonly Dictionary<string, EquipmentNamespaceContent> _content =
new(StringComparer.OrdinalIgnoreCase);
private readonly Lock _lock = new();
public EquipmentNamespaceContent? Get(string driverInstanceId)
{
lock (_lock)
{
return _content.TryGetValue(driverInstanceId, out var c) ? c : null;
}
}
public void Set(string driverInstanceId, EquipmentNamespaceContent content)
{
lock (_lock)
{
_content[driverInstanceId] = content;
}
}
public int Count
{
get { lock (_lock) { return _content.Count; } }
}
}

View File

@@ -0,0 +1,86 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
/// <summary>
/// Loads the <see cref="EquipmentNamespaceContent"/> snapshot the
/// <see cref="EquipmentNodeWalker"/> consumes, scoped to a single
/// (driverInstanceId, generationId) pair. Joins the four row sets the walker expects:
/// UnsAreas for the driver's cluster, UnsLines under those areas, Equipment bound to
/// this driver + its lines, and Tags bound to this driver + its equipment — all at the
/// supplied generation.
/// </summary>
/// <remarks>
/// <para>The walker is driver-instance-scoped (decisions #116#121 put the UNS in the
/// Equipment-kind namespace owned by one driver instance at a time), so this loader is
/// too — a single call returns one driver's worth of rows, never the whole fleet.</para>
///
/// <para>Returns <c>null</c> when the driver instance has no Equipment rows at the
/// supplied generation. The wire-in in <see cref="OpcUaApplicationHost"/> treats null as
/// "this driver has no UNS content, skip the walker and let DiscoverAsync own the whole
/// address space" — the backward-compat path for drivers whose namespace kind is not
/// Equipment (Modbus / AB CIP / TwinCAT / FOCAS).</para>
/// </remarks>
public sealed class EquipmentNamespaceContentLoader
{
private readonly OtOpcUaConfigDbContext _db;
public EquipmentNamespaceContentLoader(OtOpcUaConfigDbContext db)
{
_db = db;
}
/// <summary>
/// Load the walker-shaped snapshot for <paramref name="driverInstanceId"/> at
/// <paramref name="generationId"/>. Returns <c>null</c> when the driver has no
/// Equipment rows at that generation.
/// </summary>
public async Task<EquipmentNamespaceContent?> LoadAsync(
string driverInstanceId, long generationId, CancellationToken ct)
{
var equipment = await _db.Equipment
.AsNoTracking()
.Where(e => e.DriverInstanceId == driverInstanceId && e.GenerationId == generationId && e.Enabled)
.ToListAsync(ct).ConfigureAwait(false);
if (equipment.Count == 0)
return null;
// Filter UNS tree to only the lines + areas that host at least one Equipment bound to
// this driver — skips loading unrelated UNS branches from the cluster. LinesByArea
// grouping is driven off the Equipment rows so an empty line (no equipment) doesn't
// pull a pointless folder into the walker output.
var lineIds = equipment.Select(e => e.UnsLineId).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
var lines = await _db.UnsLines
.AsNoTracking()
.Where(l => l.GenerationId == generationId && lineIds.Contains(l.UnsLineId))
.ToListAsync(ct).ConfigureAwait(false);
var areaIds = lines.Select(l => l.UnsAreaId).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
var areas = await _db.UnsAreas
.AsNoTracking()
.Where(a => a.GenerationId == generationId && areaIds.Contains(a.UnsAreaId))
.ToListAsync(ct).ConfigureAwait(false);
// Tags belonging to this driver at this generation. Walker skips Tags with null
// EquipmentId (those are SystemPlatform-kind Galaxy tags per decision #120) but we
// load them anyway so the same rowset can drive future non-Equipment-kind walks
// without re-hitting the DB. Filtering here is a future optimization; today the
// per-tag cost is bounded by driver scope.
var tags = await _db.Tags
.AsNoTracking()
.Where(t => t.DriverInstanceId == driverInstanceId && t.GenerationId == generationId)
.ToListAsync(ct).ConfigureAwait(false);
return new EquipmentNamespaceContent(
Areas: areas,
Lines: lines,
Equipment: equipment,
Tags: tags);
}
}

View File

@@ -29,6 +29,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
private readonly StaleConfigFlag? _staleConfigFlag;
private readonly Func<string, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTier>? _tierLookup;
private readonly Func<string, string?>? _resilienceConfigLookup;
private readonly Func<string, ZB.MOM.WW.OtOpcUa.Core.OpcUa.EquipmentNamespaceContent?>? _equipmentContentLookup;
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger<OpcUaApplicationHost> _logger;
private ApplicationInstance? _application;
@@ -43,7 +44,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
NodeScopeResolver? scopeResolver = null,
StaleConfigFlag? staleConfigFlag = null,
Func<string, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTier>? tierLookup = null,
Func<string, string?>? resilienceConfigLookup = null)
Func<string, string?>? resilienceConfigLookup = null,
Func<string, ZB.MOM.WW.OtOpcUa.Core.OpcUa.EquipmentNamespaceContent?>? equipmentContentLookup = null)
{
_options = options;
_driverHost = driverHost;
@@ -54,6 +56,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
_staleConfigFlag = staleConfigFlag;
_tierLookup = tierLookup;
_resilienceConfigLookup = resilienceConfigLookup;
_equipmentContentLookup = equipmentContentLookup;
_loggerFactory = loggerFactory;
_logger = logger;
}
@@ -103,11 +106,31 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
// Drive each driver's discovery through its node manager. The node manager IS the
// IAddressSpaceBuilder; GenericDriverNodeManager captures alarm-condition sinks into
// its internal map and wires OnAlarmEvent → sink routing.
//
// ADR-001 Option A — when an EquipmentNamespaceContent is supplied for an
// Equipment-kind driver, run the EquipmentNodeWalker BEFORE the driver's DiscoverAsync
// so the UNS folder skeleton (Area/Line/Equipment) + Identification sub-folders +
// the five identifier properties (decision #121) are in place. DiscoverAsync then
// streams the driver's native shape on top; Tag rows bound to Equipment already
// materialized via the walker don't get duplicated because the driver's DiscoverAsync
// output is authoritative for its own native references only.
foreach (var nodeManager in _server.DriverNodeManagers)
{
var driverId = nodeManager.Driver.DriverInstanceId;
try
{
if (_equipmentContentLookup is not null)
{
var content = _equipmentContentLookup(driverId);
if (content is not null)
{
ZB.MOM.WW.OtOpcUa.Core.OpcUa.EquipmentNodeWalker.Walk(nodeManager, content);
_logger.LogInformation(
"UNS walker populated {Areas} area(s), {Lines} line(s), {Equipment} equipment, {Tags} tag(s) for driver {Driver}",
content.Areas.Count, content.Lines.Count, content.Equipment.Count, content.Tags.Count, driverId);
}
}
var generic = new GenericDriverNodeManager(nodeManager.Driver);
await generic.BuildAddressSpaceAsync(nodeManager, ct).ConfigureAwait(false);
_logger.LogInformation("Address space populated for driver {Driver}", driverId);

View File

@@ -1,3 +1,4 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
@@ -15,6 +16,8 @@ public sealed class OpcUaServerService(
NodeBootstrap bootstrap,
DriverHost driverHost,
OpcUaApplicationHost applicationHost,
DriverEquipmentContentRegistry equipmentContentRegistry,
IServiceScopeFactory scopeFactory,
ILogger<OpcUaServerService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -24,6 +27,15 @@ public sealed class OpcUaServerService(
var result = await bootstrap.LoadCurrentGenerationAsync(stoppingToken);
logger.LogInformation("Bootstrap complete: source={Source} generation={Gen}", result.Source, result.GenerationId);
// ADR-001 Option A — populate per-driver Equipment namespace snapshots into the
// registry before StartAsync walks the address space. The walker on the OPC UA side
// reads synchronously from the registry; pre-loading here means the hot path stays
// non-blocking + each driver pays at most one Config-DB query at bootstrap time.
// Skipped when no generation is Published yet — the fleet boots into a UNS-less
// address space until the first publish, then the registry fills on next restart.
if (result.GenerationId is { } gen)
await PopulateEquipmentContentAsync(gen, stoppingToken);
// PR 17: stand up the OPC UA server + drive discovery per registered driver. Driver
// registration itself (RegisterAsync on DriverHost) happens during an earlier DI
// extension once the central config DB query + per-driver factory land; for now the
@@ -48,4 +60,30 @@ public sealed class OpcUaServerService(
await applicationHost.DisposeAsync();
await driverHost.DisposeAsync();
}
/// <summary>
/// Pre-load an <c>EquipmentNamespaceContent</c> snapshot for each registered driver at
/// the bootstrapped generation. Null results (driver has no Equipment rows —
/// Modbus/AB CIP/TwinCAT/FOCAS today per decisions #116#121) are skipped: the walker
/// wire-in sees Get(driverId) return null + falls back to DiscoverAsync-owns-it.
/// Opens one scope so the scoped <c>OtOpcUaConfigDbContext</c> is shared across all
/// per-driver queries rather than paying scope-setup overhead per driver.
/// </summary>
private async Task PopulateEquipmentContentAsync(long generationId, CancellationToken ct)
{
using var scope = scopeFactory.CreateScope();
var loader = scope.ServiceProvider.GetRequiredService<EquipmentNamespaceContentLoader>();
var loaded = 0;
foreach (var driverId in driverHost.RegisteredDriverIds)
{
var content = await loader.LoadAsync(driverId, generationId, ct).ConfigureAwait(false);
if (content is null) continue;
equipmentContentRegistry.Set(driverId, content);
loaded++;
}
logger.LogInformation(
"Equipment namespace snapshots loaded for {Count}/{Total} driver(s) at generation {Gen}",
loaded, driverHost.RegisteredDriverIds.Count, generationId);
}
}

View File

@@ -86,7 +86,25 @@ builder.Services.AddSingleton<IUserAuthenticator>(sp => ldapOptions.Enabled
builder.Services.AddSingleton<ILocalConfigCache>(_ => new LiteDbConfigCache(options.LocalCachePath));
builder.Services.AddSingleton<DriverHost>();
builder.Services.AddSingleton<NodeBootstrap>();
builder.Services.AddSingleton<OpcUaApplicationHost>();
// ADR-001 Option A wiring — the registry is the handoff between OpcUaServerService's
// bootstrap-time population pass + OpcUaApplicationHost's StartAsync walker invocation.
// DriverEquipmentContentRegistry.Get is the equipmentContentLookup delegate that PR #155
// added to OpcUaApplicationHost's ctor seam.
builder.Services.AddSingleton<DriverEquipmentContentRegistry>();
builder.Services.AddScoped<EquipmentNamespaceContentLoader>();
builder.Services.AddSingleton<OpcUaApplicationHost>(sp =>
{
var registry = sp.GetRequiredService<DriverEquipmentContentRegistry>();
return new OpcUaApplicationHost(
sp.GetRequiredService<OpcUaServerOptions>(),
sp.GetRequiredService<DriverHost>(),
sp.GetRequiredService<IUserAuthenticator>(),
sp.GetRequiredService<ILoggerFactory>(),
sp.GetRequiredService<ILogger<OpcUaApplicationHost>>(),
equipmentContentLookup: registry.Get);
});
builder.Services.AddHostedService<OpcUaServerService>();
// Central-config DB access for the host-status publisher (LMX follow-up #7). Scoped context

View File

@@ -1,42 +1,83 @@
using System.Collections.Frozen;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
/// <summary>
/// Maps a driver-side full reference (e.g. <c>"TestMachine_001/Oven/SetPoint"</c>) to the
/// <see cref="NodeScope"/> the Phase 6.2 evaluator walks. Today a simplified resolver that
/// returns a cluster-scoped + tag-only scope — the deeper UnsArea / UnsLine / Equipment
/// path lookup from the live Configuration DB is a Stream C.12 follow-up.
/// <see cref="NodeScope"/> the Phase 6.2 evaluator walks. Supports two modes:
/// <list type="bullet">
/// <item>
/// <b>Cluster-only (pre-ADR-001)</b> — when no path index is supplied the resolver
/// returns a flat <c>ClusterId + TagId</c> scope. Sufficient while the
/// Config-DB-driven Equipment walker isn't live; Cluster-level grants cascade to every
/// tag below per decision #129, so finer per-Equipment grants are effectively
/// cluster-wide at dispatch.
/// </item>
/// <item>
/// <b>Full-path (post-ADR-001 Task B)</b> — when an index is supplied, the resolver
/// joins the full reference against the index to produce a complete
/// <c>Cluster → Namespace → UnsArea → UnsLine → Equipment → Tag</c> scope. Unblocks
/// per-Equipment / per-UnsLine ACL grants at the dispatch layer.
/// </item>
/// </list>
/// </summary>
/// <remarks>
/// <para>The flat cluster-level scope is sufficient for v2 GA because Phase 6.2 ACL grants
/// at the Cluster scope cascade to every tag below (decision #129 — additive grants). The
/// finer hierarchy only matters when operators want per-area or per-equipment grants;
/// those still work for Cluster-level grants, and landing the finer resolution in a
/// follow-up doesn't regress the base security model.</para>
/// <para>The index is pre-loaded by the Server bootstrap against the published generation;
/// the resolver itself does no live DB access. Resolve is O(1) dictionary lookup on the
/// hot path; the fallback for unknown fullReference strings produces the same cluster-only
/// scope the pre-ADR-001 resolver returned — new tags picked up via driver discovery but
/// not yet indexed (e.g. between a DiscoverAsync result and the next generation publish)
/// stay addressable without a scope-resolver crash.</para>
///
/// <para>Thread-safety: the resolver is stateless once constructed. Callers may cache a
/// single instance per DriverNodeManager without locks.</para>
/// <para>Thread-safety: both constructor paths freeze inputs into immutable state. Callers
/// may cache a single instance per DriverNodeManager without locks. Swap atomically on
/// generation change via the server's publish pipeline.</para>
/// </remarks>
public sealed class NodeScopeResolver
{
private readonly string _clusterId;
private readonly FrozenDictionary<string, NodeScope>? _index;
/// <summary>Cluster-only resolver — pre-ADR-001 behavior. Kept for Server processes that
/// haven't wired the Config-DB snapshot flow yet.</summary>
public NodeScopeResolver(string clusterId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
_clusterId = clusterId;
_index = null;
}
/// <summary>
/// Full-path resolver (ADR-001 Task B). <paramref name="pathIndex"/> maps each known
/// driver-side full reference to its pre-resolved <see cref="NodeScope"/> carrying
/// every UNS level populated. Entries are typically produced by joining
/// <c>Tag → Equipment → UnsLine → UnsArea</c> rows of the published generation against
/// the driver's discovered full references (or against <c>Tag.TagConfig</c> directly
/// when the walker is config-primary per ADR-001 Option A).
/// </summary>
public NodeScopeResolver(string clusterId, IReadOnlyDictionary<string, NodeScope> pathIndex)
{
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
ArgumentNullException.ThrowIfNull(pathIndex);
_clusterId = clusterId;
_index = pathIndex.ToFrozenDictionary(StringComparer.Ordinal);
}
/// <summary>
/// Resolve a node scope for the given driver-side <paramref name="fullReference"/>.
/// Phase 1 shape: returns <c>ClusterId</c> + <c>TagId = fullReference</c> only;
/// NamespaceId / UnsArea / UnsLine / Equipment stay null. A future resolver will
/// join against the Configuration DB to populate the full path.
/// Returns the indexed full-path scope when available; falls back to cluster-only
/// (TagId populated only) when the index is absent or the reference isn't indexed.
/// The fallback is the same shape the pre-ADR-001 resolver produced, so the authz
/// evaluator behaves identically for un-indexed references.
/// </summary>
public NodeScope Resolve(string fullReference)
{
ArgumentException.ThrowIfNullOrWhiteSpace(fullReference);
if (_index is not null && _index.TryGetValue(fullReference, out var indexed))
return indexed;
return new NodeScope
{
ClusterId = _clusterId,

View File

@@ -0,0 +1,81 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
/// <summary>
/// Builds the <see cref="NodeScope"/> path index consumed by <see cref="NodeScopeResolver"/>
/// from a Config-DB snapshot of a single published generation. Runs once per generation
/// (or on every generation change) at the Server bootstrap layer; the produced index is
/// immutable + hot-path readable per ADR-001 Task B.
/// </summary>
/// <remarks>
/// <para>The index key is the driver-side full reference (<c>Tag.TagConfig</c>) — the same
/// string the dispatch layer passes to <see cref="NodeScopeResolver.Resolve"/>. The value
/// is a <see cref="NodeScope"/> with every UNS level populated:
/// <c>ClusterId / NamespaceId / UnsAreaId / UnsLineId / EquipmentId / TagId</c>. Tag rows
/// with null <c>EquipmentId</c> (SystemPlatform-namespace Galaxy tags per decision #120)
/// are excluded from the index — the cluster-only fallback path in the resolver handles
/// them without needing an index entry.</para>
///
/// <para>Duplicate keys are not expected but would be indicative of corrupt data — the
/// builder throws <see cref="InvalidOperationException"/> on collision so a config drift
/// surfaces at bootstrap instead of producing silently-last-wins scopes at dispatch.</para>
/// </remarks>
public static class ScopePathIndexBuilder
{
/// <summary>
/// Build a fullReference → NodeScope index from the four Config-DB collections for a
/// single namespace. Callers must filter inputs to a single
/// <see cref="Namespace"/> + the same <see cref="ConfigGeneration"/> upstream.
/// </summary>
/// <param name="clusterId">Owning cluster — populates <see cref="NodeScope.ClusterId"/>.</param>
/// <param name="namespaceId">Owning namespace — populates <see cref="NodeScope.NamespaceId"/>.</param>
/// <param name="content">Pre-loaded rows for the namespace.</param>
public static IReadOnlyDictionary<string, NodeScope> Build(
string clusterId,
string namespaceId,
EquipmentNamespaceContent content)
{
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
ArgumentException.ThrowIfNullOrWhiteSpace(namespaceId);
ArgumentNullException.ThrowIfNull(content);
var areaByLine = content.Lines.ToDictionary(l => l.UnsLineId, l => l.UnsAreaId, StringComparer.OrdinalIgnoreCase);
var lineByEquipment = content.Equipment.ToDictionary(e => e.EquipmentId, e => e.UnsLineId, StringComparer.OrdinalIgnoreCase);
var index = new Dictionary<string, NodeScope>(StringComparer.Ordinal);
foreach (var tag in content.Tags)
{
// Null EquipmentId = SystemPlatform-namespace tag per decision #110 — skip; the
// cluster-only resolver fallback handles those without needing an index entry.
if (string.IsNullOrEmpty(tag.EquipmentId)) continue;
// Broken FK — Tag references a missing Equipment row. Skip rather than crash;
// sp_ValidateDraft should have caught this at publish, so any drift here is
// unexpected but non-fatal.
if (!lineByEquipment.TryGetValue(tag.EquipmentId, out var lineId)) continue;
if (!areaByLine.TryGetValue(lineId, out var areaId)) continue;
var scope = new NodeScope
{
ClusterId = clusterId,
NamespaceId = namespaceId,
UnsAreaId = areaId,
UnsLineId = lineId,
EquipmentId = tag.EquipmentId,
TagId = tag.TagConfig,
Kind = NodeHierarchyKind.Equipment,
};
if (!index.TryAdd(tag.TagConfig, scope))
throw new InvalidOperationException(
$"Duplicate fullReference '{tag.TagConfig}' in Equipment namespace '{namespaceId}'. " +
"Config data is corrupt — two Tag rows produced the same wire-level address.");
}
return index;
}
}

View File

@@ -101,7 +101,8 @@ public class SubscribeCommandTests
await task;
var output = TestConsoleHelper.GetOutput(console);
output.ShouldContain("Subscribed to ns=2;s=TestVar (interval: 2000ms)");
output.ShouldContain("Unsubscribed.");
// CLI now prints aggregate form "Subscribed to {count}/{total} nodes (interval: ...)" rather than
// the single-node form the original test asserted — the command supports multi-node now.
output.ShouldContain("Subscribed to 1/1 nodes (interval: 2000ms)");
}
}

View File

@@ -0,0 +1,48 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Client.Shared;
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests;
[Trait("Category", "Unit")]
public sealed class ClientStoragePathsTests
{
[Fact]
public void GetRoot_ReturnsCanonicalFolderName_UnderLocalAppData()
{
var root = ClientStoragePaths.GetRoot();
root.ShouldEndWith(ClientStoragePaths.CanonicalFolderName);
root.ShouldContain(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData));
}
[Fact]
public void GetPkiPath_NestsPkiUnderRoot()
{
var pki = ClientStoragePaths.GetPkiPath();
pki.ShouldEndWith(Path.Combine(ClientStoragePaths.CanonicalFolderName, "pki"));
}
[Fact]
public void CanonicalFolderName_IsOtOpcUaClient()
{
ClientStoragePaths.CanonicalFolderName.ShouldBe("OtOpcUaClient");
}
[Fact]
public void LegacyFolderName_IsLmxOpcUaClient()
{
// The shim depends on this specific spelling — a typo here would leak the legacy
// folder past the migration + break every dev-box upgrade.
ClientStoragePaths.LegacyFolderName.ShouldBe("LmxOpcUaClient");
}
[Fact]
public void TryRunLegacyMigration_Returns_False_On_Repeat_Invocation()
{
// Once the guard in-process has fired, subsequent calls short-circuit to false
// regardless of filesystem state. This is the behaviour that keeps the migration
// cheap on hot paths (CertificateStorePath property getter is called frequently).
_ = ClientStoragePaths.GetRoot(); // arms the guard
ClientStoragePaths.TryRunLegacyMigration().ShouldBeFalse();
}
}

View File

@@ -18,7 +18,7 @@ public class ConnectionSettingsTests
settings.SecurityMode.ShouldBe(SecurityMode.None);
settings.SessionTimeoutSeconds.ShouldBe(60);
settings.AutoAcceptCertificates.ShouldBeTrue();
settings.CertificateStorePath.ShouldContain("LmxOpcUaClient");
settings.CertificateStorePath.ShouldContain("OtOpcUaClient");
settings.CertificateStorePath.ShouldContain("pki");
}

View File

@@ -252,7 +252,7 @@ public class MainWindowViewModelTests
_vm.FailoverUrls.ShouldBeNull();
_vm.SessionTimeoutSeconds.ShouldBe(60);
_vm.AutoAcceptCertificates.ShouldBeTrue();
_vm.CertificateStorePath.ShouldContain("LmxOpcUaClient");
_vm.CertificateStorePath.ShouldContain("OtOpcUaClient");
_vm.CertificateStorePath.ShouldContain("pki");
}

View File

@@ -0,0 +1,221 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.OpcUa;
[Trait("Category", "Unit")]
public sealed class EquipmentNodeWalkerTests
{
[Fact]
public void Walk_EmptyContent_EmitsNothing()
{
var rec = new RecordingBuilder("root");
EquipmentNodeWalker.Walk(rec, new EquipmentNamespaceContent([], [], [], []));
rec.Children.ShouldBeEmpty();
}
[Fact]
public void Walk_EmitsArea_Line_Equipment_Folders_In_UnsOrder()
{
var content = new EquipmentNamespaceContent(
Areas: [Area("area-1", "warsaw"), Area("area-2", "berlin")],
Lines: [Line("line-1", "area-1", "oven-line"), Line("line-2", "area-2", "press-line")],
Equipment: [Eq("eq-1", "line-1", "oven-3"), Eq("eq-2", "line-2", "press-7")],
Tags: []);
var rec = new RecordingBuilder("root");
EquipmentNodeWalker.Walk(rec, content);
rec.Children.Select(c => c.BrowseName).ShouldBe(["berlin", "warsaw"]); // ordered by Name
var warsaw = rec.Children.First(c => c.BrowseName == "warsaw");
warsaw.Children.Select(c => c.BrowseName).ShouldBe(["oven-line"]);
warsaw.Children[0].Children.Select(c => c.BrowseName).ShouldBe(["oven-3"]);
}
[Fact]
public void Walk_AddsFiveIdentifierProperties_OnEquipmentNode_Skipping_NullZTagSapid()
{
var uuid = Guid.NewGuid();
var eq = Eq("eq-1", "line-1", "oven-3");
eq.EquipmentUuid = uuid;
eq.MachineCode = "MC-42";
eq.ZTag = null;
eq.SAPID = null;
var content = new EquipmentNamespaceContent(
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []);
var rec = new RecordingBuilder("root");
EquipmentNodeWalker.Walk(rec, content);
var equipmentNode = rec.Children[0].Children[0].Children[0];
var props = equipmentNode.Properties.Select(p => p.BrowseName).ToList();
props.ShouldContain("EquipmentId");
props.ShouldContain("EquipmentUuid");
props.ShouldContain("MachineCode");
props.ShouldNotContain("ZTag");
props.ShouldNotContain("SAPID");
equipmentNode.Properties.First(p => p.BrowseName == "EquipmentUuid").Value.ShouldBe(uuid.ToString());
}
[Fact]
public void Walk_Adds_ZTag_And_SAPID_When_Present()
{
var eq = Eq("eq-1", "line-1", "oven-3");
eq.ZTag = "ZT-0042";
eq.SAPID = "10000042";
var content = new EquipmentNamespaceContent(
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []);
var rec = new RecordingBuilder("root");
EquipmentNodeWalker.Walk(rec, content);
var equipmentNode = rec.Children[0].Children[0].Children[0];
equipmentNode.Properties.First(p => p.BrowseName == "ZTag").Value.ShouldBe("ZT-0042");
equipmentNode.Properties.First(p => p.BrowseName == "SAPID").Value.ShouldBe("10000042");
}
[Fact]
public void Walk_Materializes_Identification_Subfolder_When_AnyFieldPresent()
{
var eq = Eq("eq-1", "line-1", "oven-3");
eq.Manufacturer = "Trumpf";
eq.Model = "TruLaser-3030";
var content = new EquipmentNamespaceContent(
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []);
var rec = new RecordingBuilder("root");
EquipmentNodeWalker.Walk(rec, content);
var equipmentNode = rec.Children[0].Children[0].Children[0];
var identification = equipmentNode.Children.FirstOrDefault(c => c.BrowseName == "Identification");
identification.ShouldNotBeNull();
identification!.Properties.Select(p => p.BrowseName).ShouldContain("Manufacturer");
identification.Properties.Select(p => p.BrowseName).ShouldContain("Model");
}
[Fact]
public void Walk_Omits_Identification_Subfolder_When_AllFieldsNull()
{
var eq = Eq("eq-1", "line-1", "oven-3"); // no identification fields
var content = new EquipmentNamespaceContent(
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []);
var rec = new RecordingBuilder("root");
EquipmentNodeWalker.Walk(rec, content);
var equipmentNode = rec.Children[0].Children[0].Children[0];
equipmentNode.Children.ShouldNotContain(c => c.BrowseName == "Identification");
}
[Fact]
public void Walk_Emits_Variable_Per_BoundTag_Under_Equipment()
{
var eq = Eq("eq-1", "line-1", "oven-3");
var tag1 = NewTag("tag-1", "Temperature", "Int32", "plcaddr-01", equipmentId: "eq-1");
var tag2 = NewTag("tag-2", "Setpoint", "Float32", "plcaddr-02", equipmentId: "eq-1");
var unboundTag = NewTag("tag-3", "Orphan", "Int32", "plcaddr-03", equipmentId: null); // SystemPlatform-style, walker skips
var content = new EquipmentNamespaceContent(
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
[eq], [tag1, tag2, unboundTag]);
var rec = new RecordingBuilder("root");
EquipmentNodeWalker.Walk(rec, content);
var equipmentNode = rec.Children[0].Children[0].Children[0];
equipmentNode.Variables.Count.ShouldBe(2);
equipmentNode.Variables.Select(v => v.BrowseName).ShouldBe(["Setpoint", "Temperature"]);
equipmentNode.Variables.First(v => v.BrowseName == "Temperature").AttributeInfo.FullName.ShouldBe("plcaddr-01");
equipmentNode.Variables.First(v => v.BrowseName == "Setpoint").AttributeInfo.DriverDataType.ShouldBe(DriverDataType.Float32);
}
[Fact]
public void Walk_FallsBack_To_String_For_Unparseable_DataType()
{
var eq = Eq("eq-1", "line-1", "oven-3");
var tag = NewTag("tag-1", "Mystery", "NotARealType", "plcaddr-42", equipmentId: "eq-1");
var content = new EquipmentNamespaceContent(
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], [tag]);
var rec = new RecordingBuilder("root");
EquipmentNodeWalker.Walk(rec, content);
var variable = rec.Children[0].Children[0].Children[0].Variables.Single();
variable.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.String);
}
// ----- builders for test seed rows -----
private static UnsArea Area(string id, string name) => new()
{
UnsAreaId = id, ClusterId = "c1", Name = name, GenerationId = 1,
};
private static UnsLine Line(string id, string areaId, string name) => new()
{
UnsLineId = id, UnsAreaId = areaId, Name = name, GenerationId = 1,
};
private static Equipment Eq(string equipmentId, string lineId, string name) => new()
{
EquipmentRowId = Guid.NewGuid(),
GenerationId = 1,
EquipmentId = equipmentId,
EquipmentUuid = Guid.NewGuid(),
DriverInstanceId = "drv",
UnsLineId = lineId,
Name = name,
MachineCode = "MC-" + name,
};
private static Tag NewTag(string tagId, string name, string dataType, string address, string? equipmentId) => new()
{
TagRowId = Guid.NewGuid(),
GenerationId = 1,
TagId = tagId,
DriverInstanceId = "drv",
EquipmentId = equipmentId,
Name = name,
DataType = dataType,
AccessLevel = ZB.MOM.WW.OtOpcUa.Configuration.Enums.TagAccessLevel.ReadWrite,
TagConfig = address,
};
// ----- recording IAddressSpaceBuilder -----
private sealed class RecordingBuilder(string browseName) : IAddressSpaceBuilder
{
public string BrowseName { get; } = browseName;
public List<RecordingBuilder> Children { get; } = new();
public List<RecordingVariable> Variables { get; } = new();
public List<RecordingProperty> Properties { get; } = new();
public IAddressSpaceBuilder Folder(string name, string _)
{
var child = new RecordingBuilder(name);
Children.Add(child);
return child;
}
public IVariableHandle Variable(string name, string _, DriverAttributeInfo attr)
{
var v = new RecordingVariable(name, attr);
Variables.Add(v);
return v;
}
public void AddProperty(string name, DriverDataType _, object? value) =>
Properties.Add(new RecordingProperty(name, value));
}
private sealed record RecordingProperty(string BrowseName, object? Value);
private sealed record RecordingVariable(string BrowseName, DriverAttributeInfo AttributeInfo) : IVariableHandle
{
public string FullReference => AttributeInfo.FullName;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotSupportedException();
}
}

View File

@@ -0,0 +1,190 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Task #177 — tests covering ALMD projection detection, feature-flag gate,
/// subscribe/unsubscribe lifecycle, state-transition event emission, and acknowledge.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipAlarmProjectionTests
{
private const string Device = "ab://10.0.0.5/1,0";
private static AbCipTagDefinition AlmdTag(string name) => new(
name, Device, name, AbCipDataType.Structure, Members:
[
new AbCipStructureMember("InFaulted", AbCipDataType.DInt), // Logix stores ALMD bools as DINT
new AbCipStructureMember("Acked", AbCipDataType.DInt),
new AbCipStructureMember("Severity", AbCipDataType.DInt),
new AbCipStructureMember("In", AbCipDataType.DInt),
]);
[Fact]
public void AbCipAlarmDetector_Flags_AlmdSignature_As_Alarm()
{
var almd = AlmdTag("HighTemp");
AbCipAlarmDetector.IsAlmd(almd).ShouldBeTrue();
var plainUdt = new AbCipTagDefinition("Plain", Device, "Plain", AbCipDataType.Structure, Members:
[new AbCipStructureMember("X", AbCipDataType.DInt)]);
AbCipAlarmDetector.IsAlmd(plainUdt).ShouldBeFalse();
var atomic = new AbCipTagDefinition("Plain", Device, "Plain", AbCipDataType.DInt);
AbCipAlarmDetector.IsAlmd(atomic).ShouldBeFalse();
}
[Fact]
public void Severity_Mapping_Matches_OPC_UA_Convention()
{
// Logix severity 11000 — mirror the OpcUaClient ACAndC bucketing.
AbCipAlarmProjection.MapSeverity(100).ShouldBe(AlarmSeverity.Low);
AbCipAlarmProjection.MapSeverity(400).ShouldBe(AlarmSeverity.Medium);
AbCipAlarmProjection.MapSeverity(600).ShouldBe(AlarmSeverity.High);
AbCipAlarmProjection.MapSeverity(900).ShouldBe(AlarmSeverity.Critical);
}
[Fact]
public async Task FeatureFlag_Off_SubscribeAlarms_Returns_Handle_But_Never_Polls()
{
var factory = new FakeAbCipTagFactory();
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags = [AlmdTag("HighTemp")],
EnableAlarmProjection = false, // explicit; also the default
};
var drv = new AbCipDriver(opts, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
handle.ShouldNotBeNull();
handle.DiagnosticId.ShouldContain("abcip-alarm-sub-");
// Wait a touch — if polling were active, a fake member-read would be triggered.
await Task.Delay(100);
factory.Tags.ShouldNotContainKey("HighTemp.InFaulted");
factory.Tags.ShouldNotContainKey("HighTemp.Severity");
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task FeatureFlag_On_Subscribe_Starts_Polling_And_Fires_Raise_On_0_to_1()
{
var factory = new FakeAbCipTagFactory();
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags = [AlmdTag("HighTemp")],
EnableAlarmProjection = true,
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
};
var drv = new AbCipDriver(opts, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var events = new List<AlarmEventArgs>();
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
// The ALMD UDT is declared so whole-UDT grouping kicks in; the parent HighTemp runtime
// gets created + polled. Set InFaulted offset-value to 0 first (clear), wait a tick,
// then flip to 1 (fault) + wait for the raise event.
await WaitForTagCreation(factory, "HighTemp");
factory.Tags["HighTemp"].ValuesByOffset[0] = 0; // InFaulted=false at offset 0
factory.Tags["HighTemp"].ValuesByOffset[8] = 500; // Severity at offset 8 (after InFaulted+Acked)
await Task.Delay(80); // let a tick seed the "last-seen false" state
factory.Tags["HighTemp"].ValuesByOffset[0] = 1; // flip to faulted
await Task.Delay(200); // allow several polls to be safe
lock (events)
{
events.ShouldContain(e => e.SourceNodeId == "HighTemp" && e.AlarmType == "ALMD"
&& e.Message.Contains("raised"));
}
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Clear_Event_Fires_On_1_to_0_Transition()
{
var factory = new FakeAbCipTagFactory();
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags = [AlmdTag("HighTemp")],
EnableAlarmProjection = true,
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
};
var drv = new AbCipDriver(opts, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var events = new List<AlarmEventArgs>();
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
await WaitForTagCreation(factory, "HighTemp");
factory.Tags["HighTemp"].ValuesByOffset[0] = 1;
factory.Tags["HighTemp"].ValuesByOffset[8] = 500;
await Task.Delay(80); // observe raise
factory.Tags["HighTemp"].ValuesByOffset[0] = 0;
await Task.Delay(200);
lock (events)
{
events.ShouldContain(e => e.Message.Contains("raised"));
events.ShouldContain(e => e.Message.Contains("cleared"));
}
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Unsubscribe_Stops_The_Poll_Loop()
{
var factory = new FakeAbCipTagFactory();
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags = [AlmdTag("HighTemp")],
EnableAlarmProjection = true,
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
};
var drv = new AbCipDriver(opts, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
await WaitForTagCreation(factory, "HighTemp");
var preUnsubReadCount = factory.Tags["HighTemp"].ReadCount;
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
await Task.Delay(100); // well past several poll intervals if the loop were still alive
var postDelayReadCount = factory.Tags["HighTemp"].ReadCount;
// Allow at most one straggler read between the unsubscribe-cancel + the loop exit.
(postDelayReadCount - preUnsubReadCount).ShouldBeLessThanOrEqualTo(1);
await drv.ShutdownAsync(CancellationToken.None);
}
private static async Task WaitForTagCreation(FakeAbCipTagFactory factory, string tagName)
{
var deadline = DateTime.UtcNow.AddSeconds(2);
while (DateTime.UtcNow < deadline)
{
if (factory.Tags.ContainsKey(tagName)) return;
await Task.Delay(10);
}
throw new TimeoutException($"Tag {tagName} was never created by the fake factory.");
}
}

View File

@@ -0,0 +1,130 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Task #194 — ReadAsync integration tests for the whole-UDT grouping path. The fake
/// runtime records ReadCount + surfaces member values by byte offset so we can assert
/// both "one read per parent UDT" and "each member decoded at the correct offset."
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipDriverWholeUdtReadTests
{
private const string Device = "ab://10.0.0.5/1,0";
private static (AbCipDriver drv, FakeAbCipTagFactory factory) NewDriver(params AbCipTagDefinition[] tags)
{
var factory = new FakeAbCipTagFactory();
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags = tags,
};
return (new AbCipDriver(opts, "drv-1", factory), factory);
}
private static AbCipTagDefinition MotorUdt() => new(
"Motor", Device, "Motor", AbCipDataType.Structure, Members:
[
new AbCipStructureMember("Speed", AbCipDataType.DInt), // offset 0
new AbCipStructureMember("Torque", AbCipDataType.Real), // offset 4
]);
[Fact]
public async Task Two_members_of_same_udt_trigger_one_parent_read()
{
var (drv, factory) = NewDriver(MotorUdt());
await drv.InitializeAsync("{}", CancellationToken.None);
var snapshots = await drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None);
snapshots.Count.ShouldBe(2);
snapshots[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
snapshots[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
// Factory should have created ONE runtime (for the parent "Motor") + issued ONE read.
// Without the optimization two runtimes (one per member) + two reads would appear.
factory.Tags.Count.ShouldBe(1);
factory.Tags.ShouldContainKey("Motor");
factory.Tags["Motor"].ReadCount.ShouldBe(1);
}
[Fact]
public async Task Each_member_decodes_at_its_own_offset()
{
var (drv, factory) = NewDriver(MotorUdt());
await drv.InitializeAsync("{}", CancellationToken.None);
// Arrange the offset-keyed values before the read fires — the planner places
// Speed at offset 0 (DInt) and Torque at offset 4 (Real).
// The fake records CreationParams so we fetch it up front by the parent name.
var snapshotsTask = drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None);
// The factory creates the runtime inside ReadAsync; we need to set the offset map
// AFTER creation. Easier path: create the runtime on demand by reading once then
// re-arming. Instead: seed via a pre-read by constructing the fake in the factory's
// customise hook.
var snapshots = await snapshotsTask;
// First run establishes the runtime + gives the fake a chance to hold its reference.
factory.Tags["Motor"].ValuesByOffset[0] = 1234; // Speed
factory.Tags["Motor"].ValuesByOffset[4] = 9.5f; // Torque
snapshots = await drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None);
snapshots[0].Value.ShouldBe(1234);
snapshots[1].Value.ShouldBe(9.5f);
}
[Fact]
public async Task Parent_read_failure_stamps_every_grouped_member_Bad()
{
var (drv, factory) = NewDriver(MotorUdt());
await drv.InitializeAsync("{}", CancellationToken.None);
// Prime runtime existence via a first (successful) read so we can flip it to error.
await drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None);
factory.Tags["Motor"].Status = -3; // libplctag BadTimeout — mapped in AbCipStatusMapper
var snapshots = await drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None);
snapshots.Count.ShouldBe(2);
snapshots[0].StatusCode.ShouldNotBe(AbCipStatusMapper.Good);
snapshots[0].Value.ShouldBeNull();
snapshots[1].StatusCode.ShouldNotBe(AbCipStatusMapper.Good);
snapshots[1].Value.ShouldBeNull();
}
[Fact]
public async Task Mixed_batch_groups_udt_and_falls_back_atomics()
{
var plain = new AbCipTagDefinition("PlainDint", Device, "PlainDint", AbCipDataType.DInt);
var (drv, factory) = NewDriver(MotorUdt(), plain);
await drv.InitializeAsync("{}", CancellationToken.None);
var snapshots = await drv.ReadAsync(
["Motor.Speed", "PlainDint", "Motor.Torque"], CancellationToken.None);
snapshots.Count.ShouldBe(3);
// Motor parent ran one read, PlainDint ran its own read = 2 runtimes, 2 reads total.
factory.Tags.Count.ShouldBe(2);
factory.Tags.ShouldContainKey("Motor");
factory.Tags.ShouldContainKey("PlainDint");
factory.Tags["Motor"].ReadCount.ShouldBe(1);
factory.Tags["PlainDint"].ReadCount.ShouldBe(1);
}
[Fact]
public async Task Single_member_of_Udt_uses_per_tag_read_path()
{
// One member of a UDT doesn't benefit from grouping — the planner demotes to
// fallback so the member-level runtime (distinct from the parent runtime) is used,
// matching pre-#194 behavior.
var (drv, factory) = NewDriver(MotorUdt());
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["Motor.Speed"], CancellationToken.None);
factory.Tags.ShouldContainKey("Motor.Speed");
factory.Tags.ShouldNotContainKey("Motor");
}
}

View File

@@ -0,0 +1,72 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipUdtMemberLayoutTests
{
[Fact]
public void Packed_Atomics_Get_Natural_Alignment_Offsets()
{
// DInt (4 align) + Real (4) + Int (2) + LInt (8 — forces 2-byte pad before it)
var members = new[]
{
new AbCipStructureMember("A", AbCipDataType.DInt),
new AbCipStructureMember("B", AbCipDataType.Real),
new AbCipStructureMember("C", AbCipDataType.Int),
new AbCipStructureMember("D", AbCipDataType.LInt),
};
var offsets = AbCipUdtMemberLayout.TryBuild(members);
offsets.ShouldNotBeNull();
offsets!["A"].ShouldBe(0);
offsets["B"].ShouldBe(4);
offsets["C"].ShouldBe(8);
// cursor at 10 after Int; LInt needs 8-byte alignment → pad to 16
offsets["D"].ShouldBe(16);
}
[Fact]
public void SInt_Packed_Without_Padding()
{
var members = new[]
{
new AbCipStructureMember("X", AbCipDataType.SInt),
new AbCipStructureMember("Y", AbCipDataType.SInt),
new AbCipStructureMember("Z", AbCipDataType.SInt),
};
var offsets = AbCipUdtMemberLayout.TryBuild(members);
offsets!["X"].ShouldBe(0);
offsets["Y"].ShouldBe(1);
offsets["Z"].ShouldBe(2);
}
[Fact]
public void Returns_Null_When_Member_Is_Bool()
{
// BOOL storage in Logix UDTs is packed into a hidden host byte; declaration-only
// layout can't place it. Grouping opts out; per-tag read path handles the member.
var members = new[]
{
new AbCipStructureMember("A", AbCipDataType.DInt),
new AbCipStructureMember("Flag", AbCipDataType.Bool),
};
AbCipUdtMemberLayout.TryBuild(members).ShouldBeNull();
}
[Fact]
public void Returns_Null_When_Member_Is_String_Or_Structure()
{
AbCipUdtMemberLayout.TryBuild(
new[] { new AbCipStructureMember("Name", AbCipDataType.String) }).ShouldBeNull();
AbCipUdtMemberLayout.TryBuild(
new[] { new AbCipStructureMember("Nested", AbCipDataType.Structure) }).ShouldBeNull();
}
[Fact]
public void Returns_Null_On_Empty_Members()
{
AbCipUdtMemberLayout.TryBuild(Array.Empty<AbCipStructureMember>()).ShouldBeNull();
}
}

View File

@@ -0,0 +1,123 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipUdtReadPlannerTests
{
private const string Device = "ab://10.0.0.1/1,0";
[Fact]
public void Groups_Two_Members_Of_The_Same_Udt_Parent()
{
var tags = BuildUdtTagMap(out var _);
var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Speed", "Motor.Torque" }, tags);
plan.Groups.Count.ShouldBe(1);
plan.Groups[0].ParentName.ShouldBe("Motor");
plan.Groups[0].Members.Count.ShouldBe(2);
plan.Fallbacks.Count.ShouldBe(0);
}
[Fact]
public void Single_Member_Reference_Falls_Back_To_Per_Tag_Path()
{
// Reading just one member of a UDT gains nothing from grouping — one whole-UDT read
// vs one member read is equivalent cost but more client-side work. Planner demotes.
var tags = BuildUdtTagMap(out var _);
var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Speed" }, tags);
plan.Groups.ShouldBeEmpty();
plan.Fallbacks.Count.ShouldBe(1);
plan.Fallbacks[0].Reference.ShouldBe("Motor.Speed");
}
[Fact]
public void Unknown_References_Fall_Back_Without_Affecting_Groups()
{
var tags = BuildUdtTagMap(out var _);
var plan = AbCipUdtReadPlanner.Build(
new[] { "Motor.Speed", "Motor.Torque", "DoesNotExist", "Motor.NonMember" }, tags);
plan.Groups.Count.ShouldBe(1);
plan.Groups[0].Members.Count.ShouldBe(2);
plan.Fallbacks.Count.ShouldBe(2);
plan.Fallbacks.ShouldContain(f => f.Reference == "DoesNotExist");
plan.Fallbacks.ShouldContain(f => f.Reference == "Motor.NonMember");
}
[Fact]
public void Atomic_Top_Level_Tag_Falls_Back_Untouched()
{
var tags = BuildUdtTagMap(out var _);
tags = new Dictionary<string, AbCipTagDefinition>(tags, StringComparer.OrdinalIgnoreCase)
{
["PlainDint"] = new("PlainDint", Device, "PlainDint", AbCipDataType.DInt),
};
var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Speed", "Motor.Torque", "PlainDint" }, tags);
plan.Groups.Count.ShouldBe(1);
plan.Fallbacks.Count.ShouldBe(1);
plan.Fallbacks[0].Reference.ShouldBe("PlainDint");
}
[Fact]
public void Udt_With_Bool_Member_Does_Not_Group()
{
// Any BOOL in the declared members disqualifies the group — offset rules for BOOL
// can't be determined from declaration alone (Logix packs them into a hidden host
// byte). Fallback path reads each member individually.
var members = new[]
{
new AbCipStructureMember("Run", AbCipDataType.Bool),
new AbCipStructureMember("Speed", AbCipDataType.DInt),
};
var parent = new AbCipTagDefinition("Motor", Device, "Motor", AbCipDataType.Structure,
Members: members);
var tags = new Dictionary<string, AbCipTagDefinition>(StringComparer.OrdinalIgnoreCase)
{
["Motor"] = parent,
["Motor.Run"] = new("Motor.Run", Device, "Motor.Run", AbCipDataType.Bool),
["Motor.Speed"] = new("Motor.Speed", Device, "Motor.Speed", AbCipDataType.DInt),
};
var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Run", "Motor.Speed" }, tags);
plan.Groups.ShouldBeEmpty();
plan.Fallbacks.Count.ShouldBe(2);
}
[Fact]
public void Original_Indices_Preserved_For_Out_Of_Order_Batches()
{
var tags = BuildUdtTagMap(out var _);
var plan = AbCipUdtReadPlanner.Build(
new[] { "Other", "Motor.Speed", "DoesNotExist", "Motor.Torque" }, tags);
// Motor.Speed was at index 1, Motor.Torque at 3 — must survive through the plan so
// ReadAsync can write decoded values back at the right output slot.
plan.Groups.ShouldHaveSingleItem();
var group = plan.Groups[0];
group.Members.ShouldContain(m => m.OriginalIndex == 1 && m.Definition.Name == "Motor.Speed");
group.Members.ShouldContain(m => m.OriginalIndex == 3 && m.Definition.Name == "Motor.Torque");
plan.Fallbacks.ShouldContain(f => f.OriginalIndex == 0 && f.Reference == "Other");
plan.Fallbacks.ShouldContain(f => f.OriginalIndex == 2 && f.Reference == "DoesNotExist");
}
private static Dictionary<string, AbCipTagDefinition> BuildUdtTagMap(out AbCipTagDefinition parent)
{
var members = new[]
{
new AbCipStructureMember("Speed", AbCipDataType.DInt),
new AbCipStructureMember("Torque", AbCipDataType.Real),
};
parent = new AbCipTagDefinition("Motor", Device, "Motor", AbCipDataType.Structure, Members: members);
return new Dictionary<string, AbCipTagDefinition>(StringComparer.OrdinalIgnoreCase)
{
["Motor"] = parent,
["Motor.Speed"] = new("Motor.Speed", Device, "Motor.Speed", AbCipDataType.DInt),
["Motor.Torque"] = new("Motor.Torque", Device, "Motor.Torque", AbCipDataType.Real),
};
}
}

View File

@@ -47,6 +47,21 @@ internal class FakeAbCipTag : IAbCipTagRuntime
public virtual object? DecodeValue(AbCipDataType type, int? bitIndex) => Value;
/// <summary>
/// Task #194 whole-UDT read support. Tests drive multi-member decoding by setting
/// <see cref="ValuesByOffset"/> — keyed by member byte offset — before invoking
/// <see cref="AbCipDriver.ReadAsync"/>. Falls back to <see cref="Value"/> when the
/// offset is zero or unmapped so existing tests that never set the offset map keep
/// working unchanged.
/// </summary>
public Dictionary<int, object?> ValuesByOffset { get; } = new();
public virtual object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex)
{
if (ValuesByOffset.TryGetValue(offset, out var v)) return v;
return offset == 0 ? Value : null;
}
public virtual void EncodeValue(AbCipDataType type, int? bitIndex, object? value) => Value = value;
public virtual void Dispose() => Disposed = true;

View File

@@ -0,0 +1,57 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
[Trait("Category", "Unit")]
public sealed class DriverEquipmentContentRegistryTests
{
private static readonly EquipmentNamespaceContent EmptyContent =
new(Areas: [], Lines: [], Equipment: [], Tags: []);
[Fact]
public void Get_Returns_Null_For_Unknown_Driver()
{
var registry = new DriverEquipmentContentRegistry();
registry.Get("galaxy-prod").ShouldBeNull();
registry.Count.ShouldBe(0);
}
[Fact]
public void Set_Then_Get_Returns_Stored_Content()
{
var registry = new DriverEquipmentContentRegistry();
registry.Set("galaxy-prod", EmptyContent);
registry.Get("galaxy-prod").ShouldBeSameAs(EmptyContent);
registry.Count.ShouldBe(1);
}
[Fact]
public void Get_Is_Case_Insensitive_For_Driver_Id()
{
// DriverInstanceId keys are OrdinalIgnoreCase across the codebase (Equipment /
// Tag rows, walker grouping). Registry matches that contract so callers don't have
// to canonicalize driver ids before lookup.
var registry = new DriverEquipmentContentRegistry();
registry.Set("Galaxy-Prod", EmptyContent);
registry.Get("galaxy-prod").ShouldBeSameAs(EmptyContent);
registry.Get("GALAXY-PROD").ShouldBeSameAs(EmptyContent);
}
[Fact]
public void Set_Overwrites_Existing_Content_For_Same_Driver()
{
var registry = new DriverEquipmentContentRegistry();
var first = EmptyContent;
var second = new EquipmentNamespaceContent([], [], [], []);
registry.Set("galaxy-prod", first);
registry.Set("galaxy-prod", second);
registry.Get("galaxy-prod").ShouldBeSameAs(second);
registry.Count.ShouldBe(1);
}
}

View File

@@ -0,0 +1,180 @@
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
using ZB.MOM.WW.OtOpcUa.Server.Security;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
/// <summary>
/// End-to-end authz regression test for the ADR-001 Task B close-out of task #195.
/// Walks the full dispatch flow for a read against an Equipment / Identification
/// property: ScopePathIndexBuilder → NodeScopeResolver → AuthorizationGate → PermissionTrie.
/// Proves the contract the IdentificationFolderBuilder docstring promises — a user
/// without the Equipment-scope grant gets denied on the Identification sub-folder the
/// same way they would be denied on the Equipment node itself, because they share the
/// Equipment ScopeId (no new scope level for Identification per the builder's remark
/// section).
/// </summary>
[Trait("Category", "Unit")]
public sealed class EquipmentIdentificationAuthzTests
{
private const string Cluster = "c-warsaw";
private const string Namespace = "ns-plc";
[Fact]
public void Authorized_Group_Read_Granted_On_Identification_Property()
{
var (gate, resolver) = BuildEvaluator(equipmentGrantGroup: "cn=line-a-operators");
var scope = resolver.Resolve("plcaddr-manufacturer");
var identity = new FakeIdentity("alice", ["cn=line-a-operators"]);
gate.IsAllowed(identity, OpcUaOperation.Read, scope).ShouldBeTrue();
}
[Fact]
public void Unauthorized_Group_Read_Denied_On_Identification_Property()
{
// The contract in task #195 + the IdentificationFolderBuilder docstring: "a user
// without the grant gets BadUserAccessDenied on both the Equipment node + its
// Identification variables." This test proves the evaluator side of that contract;
// the BadUserAccessDenied surfacing happens in the DriverNodeManager dispatch that
// already wires AuthorizationGate.IsAllowed → StatusCodes.BadUserAccessDenied.
var (gate, resolver) = BuildEvaluator(equipmentGrantGroup: "cn=line-a-operators");
var scope = resolver.Resolve("plcaddr-manufacturer");
var identity = new FakeIdentity("bob", ["cn=other-team"]);
gate.IsAllowed(identity, OpcUaOperation.Read, scope).ShouldBeFalse();
}
[Fact]
public void Equipment_Grant_Cascades_To_Its_Identification_Properties()
{
// Identification properties share their parent Equipment's ScopeId (no new scope
// level). An Equipment-scope grant must therefore read both — the Equipment's tag
// AND its Identification properties — via the same evaluator call path.
var (gate, resolver) = BuildEvaluator(equipmentGrantGroup: "cn=line-a-operators");
var tagScope = resolver.Resolve("plcaddr-temperature");
var identityScope = resolver.Resolve("plcaddr-manufacturer");
var identity = new FakeIdentity("alice", ["cn=line-a-operators"]);
gate.IsAllowed(identity, OpcUaOperation.Read, tagScope).ShouldBeTrue();
gate.IsAllowed(identity, OpcUaOperation.Read, identityScope).ShouldBeTrue();
}
[Fact]
public void Different_Equipment_Grant_Does_Not_Leak_Across_Equipment_Boundary()
{
// Grant on oven-3; test reading a tag on press-7 (different equipment). Must deny
// so per-Equipment isolation holds at the dispatch layer — the ADR-001 Task B
// motivation for populating the full UNS path at resolve time.
var (gate, resolver) = BuildEvaluator(
equipmentGrantGroup: "cn=oven-3-operators",
equipmentIdForGrant: "eq-oven-3");
var pressScope = resolver.Resolve("plcaddr-press-7-temp"); // belongs to eq-press-7
var identity = new FakeIdentity("charlie", ["cn=oven-3-operators"]);
gate.IsAllowed(identity, OpcUaOperation.Read, pressScope).ShouldBeFalse();
}
// ----- harness -----
/// <summary>
/// Build the AuthorizationGate + NodeScopeResolver pair for a fixture with two
/// Equipment rows (oven-3 + press-7) under one UNS line, one NodeAcl grant at
/// Equipment scope for <paramref name="equipmentGrantGroup"/>, and a ScopePathIndex
/// populated via ScopePathIndexBuilder from the same Config-DB row set the
/// EquipmentNodeWalker would consume at address-space build.
/// </summary>
private static (AuthorizationGate Gate, NodeScopeResolver Resolver) BuildEvaluator(
string equipmentGrantGroup,
string equipmentIdForGrant = "eq-oven-3")
{
var (content, scopeIndex) = BuildFixture();
var resolver = new NodeScopeResolver(Cluster, scopeIndex);
var aclRow = new NodeAcl
{
NodeAclRowId = Guid.NewGuid(),
NodeAclId = Guid.NewGuid().ToString(),
GenerationId = 1,
ClusterId = Cluster,
LdapGroup = equipmentGrantGroup,
ScopeKind = NodeAclScopeKind.Equipment,
ScopeId = equipmentIdForGrant,
PermissionFlags = NodePermissions.Browse | NodePermissions.Read,
};
var paths = new Dictionary<string, NodeAclPath>
{
[equipmentIdForGrant] = new NodeAclPath(new[] { Namespace, "area-1", "line-a", equipmentIdForGrant }),
};
var cache = new PermissionTrieCache();
cache.Install(PermissionTrieBuilder.Build(Cluster, 1, [aclRow], paths));
var evaluator = new TriePermissionEvaluator(cache);
var gate = new AuthorizationGate(evaluator, strictMode: true);
_ = content;
return (gate, resolver);
}
private static (EquipmentNamespaceContent, IReadOnlyDictionary<string, NodeScope>) BuildFixture()
{
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = Cluster, Name = "warsaw", GenerationId = 1 };
var line = new UnsLine { UnsLineId = "line-a", UnsAreaId = "area-1", Name = "line-a", GenerationId = 1 };
var oven = new Equipment
{
EquipmentRowId = Guid.NewGuid(), GenerationId = 1,
EquipmentId = "eq-oven-3", EquipmentUuid = Guid.NewGuid(),
DriverInstanceId = "drv", UnsLineId = "line-a", Name = "oven-3",
MachineCode = "MC-oven-3", Manufacturer = "Trumpf",
};
var press = new Equipment
{
EquipmentRowId = Guid.NewGuid(), GenerationId = 1,
EquipmentId = "eq-press-7", EquipmentUuid = Guid.NewGuid(),
DriverInstanceId = "drv", UnsLineId = "line-a", Name = "press-7",
MachineCode = "MC-press-7",
};
// Two tags for oven-3, one for press-7. Use Tag.TagConfig as the driver-side full
// reference the dispatch layer passes to NodeScopeResolver.Resolve.
var tempTag = NewTag("tag-1", "Temperature", "Int32", "plcaddr-temperature", "eq-oven-3");
var mfgTag = NewTag("tag-2", "Manufacturer", "String", "plcaddr-manufacturer", "eq-oven-3");
var pressTempTag = NewTag("tag-3", "PressTemp", "Int32", "plcaddr-press-7-temp", "eq-press-7");
var content = new EquipmentNamespaceContent(
Areas: [area],
Lines: [line],
Equipment: [oven, press],
Tags: [tempTag, mfgTag, pressTempTag]);
var index = ScopePathIndexBuilder.Build(Cluster, Namespace, content);
return (content, index);
}
private static Tag NewTag(string tagId, string name, string dataType, string address, string equipmentId) => new()
{
TagRowId = Guid.NewGuid(), GenerationId = 1, TagId = tagId,
DriverInstanceId = "drv", EquipmentId = equipmentId, Name = name,
DataType = dataType, AccessLevel = TagAccessLevel.ReadWrite, TagConfig = address,
};
private sealed class FakeIdentity : UserIdentity, ILdapGroupsBearer
{
public FakeIdentity(string name, IReadOnlyList<string> groups)
{
DisplayName = name;
LdapGroups = groups;
}
public new string DisplayName { get; }
public IReadOnlyList<string> LdapGroups { get; }
}
}

View File

@@ -0,0 +1,172 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
[Trait("Category", "Unit")]
public sealed class EquipmentNamespaceContentLoaderTests : IDisposable
{
private const string DriverId = "galaxy-prod";
private const string OtherDriverId = "galaxy-dev";
private const long Gen = 5;
private readonly OtOpcUaConfigDbContext _db;
private readonly EquipmentNamespaceContentLoader _loader;
public EquipmentNamespaceContentLoaderTests()
{
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase($"eq-content-loader-{Guid.NewGuid():N}")
.Options;
_db = new OtOpcUaConfigDbContext(options);
_loader = new EquipmentNamespaceContentLoader(_db);
}
public void Dispose() => _db.Dispose();
[Fact]
public async Task Returns_Null_When_Driver_Has_No_Equipment_At_Generation()
{
var result = await _loader.LoadAsync(DriverId, Gen, CancellationToken.None);
result.ShouldBeNull();
}
[Fact]
public async Task Loads_Areas_Lines_Equipment_Tags_For_Driver_At_Generation()
{
SeedBaseline();
var result = await _loader.LoadAsync(DriverId, Gen, CancellationToken.None);
result.ShouldNotBeNull();
result!.Areas.ShouldHaveSingleItem().UnsAreaId.ShouldBe("area-1");
result.Lines.ShouldHaveSingleItem().UnsLineId.ShouldBe("line-a");
result.Equipment.Count.ShouldBe(2);
result.Equipment.ShouldContain(e => e.EquipmentId == "eq-oven-3");
result.Equipment.ShouldContain(e => e.EquipmentId == "eq-press-7");
result.Tags.Count.ShouldBe(2);
result.Tags.ShouldContain(t => t.TagId == "tag-temp");
result.Tags.ShouldContain(t => t.TagId == "tag-press");
}
[Fact]
public async Task Skips_Other_Drivers_Equipment()
{
SeedBaseline();
// Equipment + Tag owned by a different driver at the same generation — must not leak.
_db.Equipment.Add(new Equipment
{
EquipmentRowId = Guid.NewGuid(), GenerationId = Gen,
EquipmentId = "eq-other", EquipmentUuid = Guid.NewGuid(),
DriverInstanceId = OtherDriverId, UnsLineId = "line-a", Name = "other-eq",
MachineCode = "MC-other",
});
_db.Tags.Add(new Tag
{
TagRowId = Guid.NewGuid(), GenerationId = Gen, TagId = "tag-other",
DriverInstanceId = OtherDriverId, EquipmentId = "eq-other",
Name = "OtherTag", DataType = "Int32",
AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "plcaddr-other",
});
await _db.SaveChangesAsync();
var result = await _loader.LoadAsync(DriverId, Gen, CancellationToken.None);
result.ShouldNotBeNull();
result!.Equipment.ShouldNotContain(e => e.EquipmentId == "eq-other");
result.Tags.ShouldNotContain(t => t.TagId == "tag-other");
}
[Fact]
public async Task Skips_Other_Generations()
{
SeedBaseline();
// Same driver, different generation — must not leak in. Walker consumes one sealed
// generation per bootstrap per decision #148.
_db.Equipment.Add(new Equipment
{
EquipmentRowId = Guid.NewGuid(), GenerationId = 99,
EquipmentId = "eq-futuristic", EquipmentUuid = Guid.NewGuid(),
DriverInstanceId = DriverId, UnsLineId = "line-a", Name = "futuristic",
MachineCode = "MC-fut",
});
await _db.SaveChangesAsync();
var result = await _loader.LoadAsync(DriverId, Gen, CancellationToken.None);
result.ShouldNotBeNull();
result!.Equipment.ShouldNotContain(e => e.EquipmentId == "eq-futuristic");
}
[Fact]
public async Task Skips_Disabled_Equipment()
{
SeedBaseline();
_db.Equipment.Add(new Equipment
{
EquipmentRowId = Guid.NewGuid(), GenerationId = Gen,
EquipmentId = "eq-disabled", EquipmentUuid = Guid.NewGuid(),
DriverInstanceId = DriverId, UnsLineId = "line-a", Name = "disabled-eq",
MachineCode = "MC-dis", Enabled = false,
});
await _db.SaveChangesAsync();
var result = await _loader.LoadAsync(DriverId, Gen, CancellationToken.None);
result.ShouldNotBeNull();
result!.Equipment.ShouldNotContain(e => e.EquipmentId == "eq-disabled");
}
private void SeedBaseline()
{
_db.UnsAreas.Add(new UnsArea
{
UnsAreaRowId = Guid.NewGuid(), UnsAreaId = "area-1", ClusterId = "c-warsaw",
Name = "warsaw", GenerationId = Gen,
});
_db.UnsLines.Add(new UnsLine
{
UnsLineRowId = Guid.NewGuid(), UnsLineId = "line-a", UnsAreaId = "area-1",
Name = "line-a", GenerationId = Gen,
});
_db.Equipment.AddRange(
new Equipment
{
EquipmentRowId = Guid.NewGuid(), GenerationId = Gen,
EquipmentId = "eq-oven-3", EquipmentUuid = Guid.NewGuid(),
DriverInstanceId = DriverId, UnsLineId = "line-a", Name = "oven-3",
MachineCode = "MC-oven-3",
},
new Equipment
{
EquipmentRowId = Guid.NewGuid(), GenerationId = Gen,
EquipmentId = "eq-press-7", EquipmentUuid = Guid.NewGuid(),
DriverInstanceId = DriverId, UnsLineId = "line-a", Name = "press-7",
MachineCode = "MC-press-7",
});
_db.Tags.AddRange(
new Tag
{
TagRowId = Guid.NewGuid(), GenerationId = Gen, TagId = "tag-temp",
DriverInstanceId = DriverId, EquipmentId = "eq-oven-3",
Name = "Temperature", DataType = "Int32",
AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "plcaddr-temperature",
},
new Tag
{
TagRowId = Guid.NewGuid(), GenerationId = Gen, TagId = "tag-press",
DriverInstanceId = DriverId, EquipmentId = "eq-press-7",
Name = "PressTemp", DataType = "Int32",
AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "plcaddr-press-temp",
});
_db.SaveChanges();
}
}

View File

@@ -21,19 +21,59 @@ public sealed class NodeScopeResolverTests
}
[Fact]
public void Resolve_Leaves_UnsPath_Null_For_Phase1()
public void Resolve_Leaves_UnsPath_Null_When_NoIndexSupplied()
{
var resolver = new NodeScopeResolver("c-1");
var scope = resolver.Resolve("tag-1");
// Phase 1 flat scope — finer resolution tracked as Stream C.12 follow-up.
// Cluster-only fallback path — used pre-ADR-001 and still the active path for
// unindexed references (e.g. driver-discovered tags that have no Tag row yet).
scope.NamespaceId.ShouldBeNull();
scope.UnsAreaId.ShouldBeNull();
scope.UnsLineId.ShouldBeNull();
scope.EquipmentId.ShouldBeNull();
}
[Fact]
public void Resolve_Returns_IndexedScope_When_FullReferenceFound()
{
var index = new Dictionary<string, NodeScope>
{
["plcaddr-01"] = new NodeScope
{
ClusterId = "c-1", NamespaceId = "ns-plc", UnsAreaId = "area-1",
UnsLineId = "line-a", EquipmentId = "eq-oven-3", TagId = "plcaddr-01",
Kind = NodeHierarchyKind.Equipment,
},
};
var resolver = new NodeScopeResolver("c-1", index);
var scope = resolver.Resolve("plcaddr-01");
scope.UnsAreaId.ShouldBe("area-1");
scope.UnsLineId.ShouldBe("line-a");
scope.EquipmentId.ShouldBe("eq-oven-3");
scope.TagId.ShouldBe("plcaddr-01");
scope.NamespaceId.ShouldBe("ns-plc");
}
[Fact]
public void Resolve_FallsBack_To_ClusterOnly_When_Reference_NotIndexed()
{
var index = new Dictionary<string, NodeScope>
{
["plcaddr-01"] = new NodeScope { ClusterId = "c-1", TagId = "plcaddr-01", Kind = NodeHierarchyKind.Equipment },
};
var resolver = new NodeScopeResolver("c-1", index);
var scope = resolver.Resolve("not-in-index");
scope.ClusterId.ShouldBe("c-1");
scope.TagId.ShouldBe("not-in-index");
scope.EquipmentId.ShouldBeNull();
}
[Fact]
public void Resolve_Throws_OnEmptyFullReference()
{

View File

@@ -0,0 +1,205 @@
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
using ZB.MOM.WW.OtOpcUa.Server.Security;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
/// <summary>
/// End-to-end proof that ADR-001 Option A wire-in (#212) flows: when
/// <see cref="OpcUaApplicationHost"/> is given an <c>equipmentContentLookup</c> that
/// returns a non-null <see cref="EquipmentNamespaceContent"/>, the walker runs BEFORE
/// the driver's DiscoverAsync + the UNS folder skeleton (Area → Line → Equipment) +
/// identifier properties are materialized into the driver's namespace + visible to an
/// OPC UA client via standard browse.
/// </summary>
[Trait("Category", "Integration")]
public sealed class OpcUaEquipmentWalkerIntegrationTests : IAsyncLifetime
{
private static readonly int Port = 48500 + Random.Shared.Next(0, 99);
private readonly string _endpoint = $"opc.tcp://localhost:{Port}/OtOpcUaWalkerTest";
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-walker-{Guid.NewGuid():N}");
private const string DriverId = "galaxy-prod";
private DriverHost _driverHost = null!;
private OpcUaApplicationHost _server = null!;
public async ValueTask InitializeAsync()
{
_driverHost = new DriverHost();
await _driverHost.RegisterAsync(new EmptyDriver(DriverId), "{}", CancellationToken.None);
var content = BuildFixture();
var options = new OpcUaServerOptions
{
EndpointUrl = _endpoint,
ApplicationName = "OtOpcUaWalkerTest",
ApplicationUri = "urn:OtOpcUa:Server:WalkerTest",
PkiStoreRoot = _pkiRoot,
AutoAcceptUntrustedClientCertificates = true,
HealthEndpointsEnabled = false,
};
_server = new OpcUaApplicationHost(
options, _driverHost, new DenyAllUserAuthenticator(),
NullLoggerFactory.Instance, NullLogger<OpcUaApplicationHost>.Instance,
equipmentContentLookup: id => id == DriverId ? content : null);
await _server.StartAsync(CancellationToken.None);
}
public async ValueTask DisposeAsync()
{
await _server.DisposeAsync();
await _driverHost.DisposeAsync();
try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort */ }
}
[Fact]
public async Task Walker_Materializes_Area_Line_Equipment_Folders_Visible_Via_Browse()
{
using var session = await OpenSessionAsync();
var nsIndex = (ushort)session.NamespaceUris.GetIndex($"urn:OtOpcUa:{DriverId}");
var areaFolder = new NodeId($"{DriverId}/warsaw", nsIndex);
var lineFolder = new NodeId($"{DriverId}/warsaw/line-a", nsIndex);
var equipmentFolder = new NodeId($"{DriverId}/warsaw/line-a/oven-3", nsIndex);
BrowseChildren(session, areaFolder).ShouldContain(r => r.BrowseName.Name == "line-a");
BrowseChildren(session, lineFolder).ShouldContain(r => r.BrowseName.Name == "oven-3");
var equipmentChildren = BrowseChildren(session, equipmentFolder);
equipmentChildren.ShouldContain(r => r.BrowseName.Name == "EquipmentId");
equipmentChildren.ShouldContain(r => r.BrowseName.Name == "EquipmentUuid");
equipmentChildren.ShouldContain(r => r.BrowseName.Name == "MachineCode");
}
[Fact]
public async Task Walker_Emits_Tag_Variable_Under_Equipment_Readable_By_Client()
{
using var session = await OpenSessionAsync();
var nsIndex = (ushort)session.NamespaceUris.GetIndex($"urn:OtOpcUa:{DriverId}");
var tagNode = new NodeId("plcaddr-temperature", nsIndex);
var equipmentFolder = new NodeId($"{DriverId}/warsaw/line-a/oven-3", nsIndex);
BrowseChildren(session, equipmentFolder).ShouldContain(r => r.BrowseName.Name == "Temperature");
var dv = session.ReadValue(tagNode);
dv.ShouldNotBeNull();
}
private static ReferenceDescriptionCollection BrowseChildren(ISession session, NodeId node)
{
session.Browse(null, null, node, 0, BrowseDirection.Forward,
ReferenceTypeIds.HierarchicalReferences, true,
(uint)NodeClass.Object | (uint)NodeClass.Variable,
out _, out var refs);
return refs;
}
private static EquipmentNamespaceContent BuildFixture()
{
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c-local", Name = "warsaw", GenerationId = 1 };
var line = new UnsLine { UnsLineId = "line-a", UnsAreaId = "area-1", Name = "line-a", GenerationId = 1 };
var oven = new Equipment
{
EquipmentRowId = Guid.NewGuid(), GenerationId = 1,
EquipmentId = "eq-oven-3", EquipmentUuid = Guid.NewGuid(),
DriverInstanceId = DriverId, UnsLineId = "line-a", Name = "oven-3",
MachineCode = "MC-oven-3",
};
var tempTag = new Tag
{
TagRowId = Guid.NewGuid(), GenerationId = 1, TagId = "tag-1",
DriverInstanceId = DriverId, EquipmentId = "eq-oven-3",
Name = "Temperature", DataType = "Int32",
AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "plcaddr-temperature",
};
return new EquipmentNamespaceContent(
Areas: new[] { area },
Lines: new[] { line },
Equipment: new[] { oven },
Tags: new[] { tempTag });
}
private async Task<ISession> OpenSessionAsync()
{
var cfg = new ApplicationConfiguration
{
ApplicationName = "OtOpcUaWalkerTestClient",
ApplicationUri = "urn:OtOpcUa:WalkerTestClient",
ApplicationType = ApplicationType.Client,
SecurityConfiguration = new SecurityConfiguration
{
ApplicationCertificate = new CertificateIdentifier
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(_pkiRoot, "client-own"),
SubjectName = "CN=OtOpcUaWalkerTestClient",
},
TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-issuers") },
TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-trusted") },
RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-rejected") },
AutoAcceptUntrustedCertificates = true,
AddAppCertToTrustedStore = true,
},
TransportConfigurations = new TransportConfigurationCollection(),
TransportQuotas = new TransportQuotas { OperationTimeout = 15000 },
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 },
};
await cfg.Validate(ApplicationType.Client);
cfg.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
var instance = new ApplicationInstance { ApplicationConfiguration = cfg, ApplicationType = ApplicationType.Client };
await instance.CheckApplicationInstanceCertificate(true, CertificateFactory.DefaultKeySize);
var selected = CoreClientUtils.SelectEndpoint(cfg, _endpoint, useSecurity: false);
var endpointConfig = EndpointConfiguration.Create(cfg);
var configuredEndpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
return await Session.Create(cfg, configuredEndpoint, false, "OtOpcUaWalkerTestClientSession", 60000,
new UserIdentity(new AnonymousIdentityToken()), null);
}
/// <summary>
/// Driver that registers into the host + implements DiscoverAsync as a no-op. The
/// walker is the sole source of address-space content; if the UNS folders appear
/// under browse, they came from the wire-in (not from the driver's own discovery).
/// </summary>
private sealed class EmptyDriver : IDriver, ITagDiscovery, IReadable
{
public EmptyDriver(string id) { DriverInstanceId = id; }
public string DriverInstanceId { get; }
public string DriverType => "EmptyForWalkerTest";
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
public long GetMemoryFootprint() => 0;
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct) => Task.CompletedTask;
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
var now = DateTime.UtcNow;
IReadOnlyList<DataValueSnapshot> result =
fullReferences.Select(_ => new DataValueSnapshot(0, 0u, now, now)).ToArray();
return Task.FromResult(result);
}
}
}