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 ## 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 ## Library Preferences
- **Logging**: Serilog with rolling daily file sink - **Logging**: Serilog with rolling daily file sink
- **Unit tests**: xUnit + Shouldly for assertions - **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**: OPC Foundation UA .NET Standard stack (https://github.com/opcfoundation/ua-.netstandard) — NuGet: `OPCFoundation.NetStandard.Opc.Ua.Server`
## OPC UA .NET Standard Documentation ## OPC UA .NET Standard Documentation

View File

@@ -14,7 +14,7 @@ dotnet build
dotnet run -- <command> [options] 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 ## 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. 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 ```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 ### 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. 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 ```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 ### Transport Security
When `sign` or `encrypt` is specified, the shared service: 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 2. Discovers server endpoints and selects one matching the requested security mode
3. Prefers `Basic256Sha256` when multiple matching endpoints exist 3. Prefers `Basic256Sha256` when multiple matching endpoints exist
4. Fails with a clear error if no matching endpoint is found 4. Fails with a clear error if no matching endpoint is found
```bash ```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 ### 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. Tests connectivity to an OPC UA server. Creates a session, prints connection metadata, and disconnects.
```bash ```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: Output:
@@ -99,7 +99,7 @@ Connection successful.
Reads the current value of a single node and prints the value, status code, and timestamps. Reads the current value of a single node and prints the value, status code, and timestamps.
```bash ```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 | | 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()`. 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 ```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 | | Flag | Description |
@@ -135,10 +135,10 @@ Browses the OPC UA address space starting from the Objects folder or a specified
```bash ```bash
# Browse top-level Objects folder # 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 # 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 | | 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. 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 ```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 | | Flag | Description |
@@ -166,12 +166,12 @@ Reads historical data from a node. Supports raw history reads and aggregate (pro
```bash ```bash
# Raw history # 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" \ -n "ns=1;s=TestMachine_001.TestHistoryValue" \
--start "2026-03-25" --end "2026-03-30" --start "2026-03-25" --end "2026-03-30"
# Aggregate: 1-hour average # 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" \ -n "ns=1;s=TestMachine_001.TestHistoryValue" \
--start "2026-03-25" --end "2026-03-30" \ --start "2026-03-25" --end "2026-03-30" \
--aggregate Average --interval 3600000 --aggregate Average --interval 3600000
@@ -203,10 +203,10 @@ Subscribes to alarm events on a node. Prints structured alarm output including s
```bash ```bash
# Subscribe to alarm events on the Server node # 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 # 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 -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. Reads the OPC UA redundancy state from a server: redundancy mode, service level, server URIs, and application URI.
```bash ```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: Example output:

View File

@@ -65,7 +65,7 @@ The top bar provides the endpoint URL, Connect, and Disconnect buttons. The **Co
### Settings Persistence ### 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 - All connection parameters
- Active subscription node IDs (restored after reconnection) - 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) # 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 > 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. > 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. 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. 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 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 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 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)!; if (type.IsSubclassOf(typeof(CommandBase))) return Activator.CreateInstance(type, CommandBase.DefaultFactory)!;
return Activator.CreateInstance(type)!; return Activator.CreateInstance(type)!;
}) })
.SetExecutableName("lmxopcua-cli") .SetExecutableName("otopcua-cli")
.SetDescription("LmxOpcUa CLI - command-line client for the LmxOpcUa OPC UA server") .SetDescription("OtOpcUa CLI - command-line client for the OtOpcUa OPC UA server")
.Build() .Build()
.RunAsync(args); .RunAsync(args);

View File

@@ -18,8 +18,8 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi
var config = new ApplicationConfiguration var config = new ApplicationConfiguration
{ {
ApplicationName = "LmxOpcUaClient", ApplicationName = "OtOpcUaClient",
ApplicationUri = "urn:localhost:LmxOpcUaClient", ApplicationUri = "urn:localhost:OtOpcUaClient",
ApplicationType = ApplicationType.Client, ApplicationType = ApplicationType.Client,
SecurityConfiguration = new SecurityConfiguration SecurityConfiguration = new SecurityConfiguration
{ {
@@ -60,7 +60,7 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi
{ {
var app = new ApplicationInstance var app = new ApplicationInstance
{ {
ApplicationName = "LmxOpcUaClient", ApplicationName = "OtOpcUaClient",
ApplicationType = ApplicationType.Client, ApplicationType = ApplicationType.Client,
ApplicationConfiguration = config 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; public bool AutoAcceptCertificates { get; set; } = true;
/// <summary> /// <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> /// </summary>
public string CertificateStorePath { get; set; } = Path.Combine( public string CertificateStorePath { get; set; } = ClientStoragePaths.GetPkiPath();
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LmxOpcUaClient", "pki");
/// <summary> /// <summary>
/// Validates the settings and throws if any required values are missing or invalid. /// 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(); : new UserIdentity();
var sessionTimeoutMs = (uint)(settings.SessionTimeoutSeconds * 1000); 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); ct);
} }

View File

@@ -1,4 +1,5 @@
using System.Text.Json; using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Client.Shared;
namespace ZB.MOM.WW.OtOpcUa.Client.UI.Services; namespace ZB.MOM.WW.OtOpcUa.Client.UI.Services;
@@ -7,9 +8,9 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI.Services;
/// </summary> /// </summary>
public sealed class JsonSettingsService : ISettingsService public sealed class JsonSettingsService : ISettingsService
{ {
private static readonly string SettingsDir = Path.Combine( // ClientStoragePaths.GetRoot runs the one-shot legacy-folder migration so pre-#208
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), // developer boxes pick up their existing settings.json on first launch post-rename.
"LmxOpcUaClient"); private static readonly string SettingsDir = ClientStoragePaths.GetRoot();
private static readonly string SettingsPath = Path.Combine(SettingsDir, "settings.json"); 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 bool _autoAcceptCertificates = true;
[ObservableProperty] private string _certificateStorePath = Path.Combine( [ObservableProperty] private string _certificateStorePath = ClientStoragePaths.GetPkiPath();
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LmxOpcUaClient", "pki");
[ObservableProperty] [ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ConnectCommand))] [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> /// <see cref="PlcTagHandle"/> and reconnects each device.</para>
/// </remarks> /// </remarks>
public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable, public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable
{ {
private readonly AbCipDriverOptions _options; private readonly AbCipDriverOptions _options;
private readonly string _driverInstanceId; private readonly string _driverInstanceId;
@@ -32,10 +32,15 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private readonly PollGroupEngine _poll; private readonly PollGroupEngine _poll;
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, AbCipTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, AbCipTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
private readonly AbCipAlarmProjection _alarmProjection;
private DriverHealth _health = new(DriverState.Unknown, null, null); private DriverHealth _health = new(DriverState.Unknown, null, null);
public event EventHandler<DataChangeEventArgs>? OnDataChange; public event EventHandler<DataChangeEventArgs>? OnDataChange;
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged; 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, public AbCipDriver(AbCipDriverOptions options, string driverInstanceId,
IAbCipTagFactory? tagFactory = null, IAbCipTagFactory? tagFactory = null,
@@ -52,6 +57,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
reader: ReadAsync, reader: ReadAsync,
onChange: (handle, tagRef, snapshot) => onChange: (handle, tagRef, snapshot) =>
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot))); OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
_alarmProjection = new AbCipAlarmProjection(this, _options.AlarmPollInterval);
} }
/// <summary> /// <summary>
@@ -162,6 +168,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public async Task ShutdownAsync(CancellationToken cancellationToken) public async Task ShutdownAsync(CancellationToken cancellationToken)
{ {
await _alarmProjection.DisposeAsync().ConfigureAwait(false);
await _poll.DisposeAsync().ConfigureAwait(false); await _poll.DisposeAsync().ConfigureAwait(false);
foreach (var state in _devices.Values) foreach (var state in _devices.Values)
{ {
@@ -187,6 +194,39 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
return Task.CompletedTask; 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 ---- // ---- IHostConnectivityProbe ----
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() => public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() =>
@@ -287,56 +327,127 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var results = new DataValueSnapshot[fullReferences.Count]; 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
var reference = fullReferences[i]; // per-tag path that's been here since PR 3. Planner is a pure function over the
if (!_tagsByName.TryGetValue(reference, out var def)) // current tag map; BOOL/String/Structure members stay on the fallback path because
{ // declaration-only offsets can't place them under Logix alignment rules.
results[i] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now); var plan = AbCipUdtReadPlanner.Build(fullReferences, _tagsByName);
continue;
}
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
{
results[i] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
continue;
}
try foreach (var group in plan.Groups)
{ await ReadGroupAsync(group, results, now, cancellationToken).ConfigureAwait(false);
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
await runtime.ReadAsync(cancellationToken).ConfigureAwait(false);
var status = runtime.GetStatus(); foreach (var fb in plan.Fallbacks)
if (status != 0) await ReadSingleAsync(fb, fullReferences[fb.OriginalIndex], results, now, cancellationToken).ConfigureAwait(false);
{
results[i] = new DataValueSnapshot(null,
AbCipStatusMapper.MapLibplctagStatus(status), null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"libplctag status {status} reading {reference}");
continue;
}
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);
_health = new DriverHealth(DriverState.Healthy, now, null);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
results[i] = new DataValueSnapshot(null,
AbCipStatusMapper.BadCommunicationError, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
}
}
return results; return results;
} }
private async Task ReadSingleAsync(
AbCipUdtReadFallback fb, string reference, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
{
if (!_tagsByName.TryGetValue(reference, out var def))
{
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
return;
}
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
{
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
return;
}
try
{
var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
await runtime.ReadAsync(ct).ConfigureAwait(false);
var status = runtime.GetStatus();
if (status != 0)
{
results[fb.OriginalIndex] = new DataValueSnapshot(null,
AbCipStatusMapper.MapLibplctagStatus(status), null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"libplctag status {status} reading {reference}");
return;
}
var tagPath = AbCipTagPath.TryParse(def.TagPath);
var bitIndex = tagPath?.BitIndex;
var value = runtime.DecodeValue(def.DataType, bitIndex);
results[fb.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
_health = new DriverHealth(DriverState.Healthy, now, null);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
results[fb.OriginalIndex] = new DataValueSnapshot(null,
AbCipStatusMapper.BadCommunicationError, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
}
}
/// <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 ---- // ---- IWritable ----
/// <summary> /// <summary>

View File

@@ -38,6 +38,24 @@ public sealed class AbCipDriverOptions
/// should appear in the address space. /// should appear in the address space.
/// </summary> /// </summary>
public bool EnableControllerBrowse { get; init; } 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> /// <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> /// </summary>
object? DecodeValue(AbCipDataType type, int? bitIndex); 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> /// <summary>
/// Encode <paramref name="value"/> into the local buffer per the tag's type. Callers /// Encode <paramref name="value"/> into the local buffer per the tag's type. Callers
/// pair this with <see cref="WriteAsync"/>. /// pair this with <see cref="WriteAsync"/>.

View File

@@ -32,24 +32,26 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
public int GetStatus() => (int)_tag.GetStatus(); 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 AbCipDataType.Bool => bitIndex is int bit
? _tag.GetBit(bit) ? _tag.GetBit(bit)
: _tag.GetInt8(0) != 0, : _tag.GetInt8(offset) != 0,
AbCipDataType.SInt => (int)(sbyte)_tag.GetInt8(0), AbCipDataType.SInt => (int)(sbyte)_tag.GetInt8(offset),
AbCipDataType.USInt => (int)_tag.GetUInt8(0), AbCipDataType.USInt => (int)_tag.GetUInt8(offset),
AbCipDataType.Int => (int)_tag.GetInt16(0), AbCipDataType.Int => (int)_tag.GetInt16(offset),
AbCipDataType.UInt => (int)_tag.GetUInt16(0), AbCipDataType.UInt => (int)_tag.GetUInt16(offset),
AbCipDataType.DInt => _tag.GetInt32(0), AbCipDataType.DInt => _tag.GetInt32(offset),
AbCipDataType.UDInt => (int)_tag.GetUInt32(0), AbCipDataType.UDInt => (int)_tag.GetUInt32(offset),
AbCipDataType.LInt => _tag.GetInt64(0), AbCipDataType.LInt => _tag.GetInt64(offset),
AbCipDataType.ULInt => (long)_tag.GetUInt64(0), AbCipDataType.ULInt => (long)_tag.GetUInt64(offset),
AbCipDataType.Real => _tag.GetFloat32(0), AbCipDataType.Real => _tag.GetFloat32(offset),
AbCipDataType.LReal => _tag.GetFloat64(0), AbCipDataType.LReal => _tag.GetFloat64(offset),
AbCipDataType.String => _tag.GetString(0), AbCipDataType.String => _tag.GetString(offset),
AbCipDataType.Dt => _tag.GetInt32(0), // seconds-since-epoch DINT; consumer widens as needed AbCipDataType.Dt => _tag.GetInt32(offset),
AbCipDataType.Structure => null, // UDT whole-tag decode lands in PR 6 AbCipDataType.Structure => null,
_ => 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 StaleConfigFlag? _staleConfigFlag;
private readonly Func<string, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTier>? _tierLookup; private readonly Func<string, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTier>? _tierLookup;
private readonly Func<string, string?>? _resilienceConfigLookup; private readonly Func<string, string?>? _resilienceConfigLookup;
private readonly Func<string, ZB.MOM.WW.OtOpcUa.Core.OpcUa.EquipmentNamespaceContent?>? _equipmentContentLookup;
private readonly ILoggerFactory _loggerFactory; private readonly ILoggerFactory _loggerFactory;
private readonly ILogger<OpcUaApplicationHost> _logger; private readonly ILogger<OpcUaApplicationHost> _logger;
private ApplicationInstance? _application; private ApplicationInstance? _application;
@@ -43,7 +44,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
NodeScopeResolver? scopeResolver = null, NodeScopeResolver? scopeResolver = null,
StaleConfigFlag? staleConfigFlag = null, StaleConfigFlag? staleConfigFlag = null,
Func<string, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTier>? tierLookup = 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; _options = options;
_driverHost = driverHost; _driverHost = driverHost;
@@ -54,6 +56,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
_staleConfigFlag = staleConfigFlag; _staleConfigFlag = staleConfigFlag;
_tierLookup = tierLookup; _tierLookup = tierLookup;
_resilienceConfigLookup = resilienceConfigLookup; _resilienceConfigLookup = resilienceConfigLookup;
_equipmentContentLookup = equipmentContentLookup;
_loggerFactory = loggerFactory; _loggerFactory = loggerFactory;
_logger = logger; _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 // Drive each driver's discovery through its node manager. The node manager IS the
// IAddressSpaceBuilder; GenericDriverNodeManager captures alarm-condition sinks into // IAddressSpaceBuilder; GenericDriverNodeManager captures alarm-condition sinks into
// its internal map and wires OnAlarmEvent → sink routing. // 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) foreach (var nodeManager in _server.DriverNodeManagers)
{ {
var driverId = nodeManager.Driver.DriverInstanceId; var driverId = nodeManager.Driver.DriverInstanceId;
try 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); var generic = new GenericDriverNodeManager(nodeManager.Driver);
await generic.BuildAddressSpaceAsync(nodeManager, ct).ConfigureAwait(false); await generic.BuildAddressSpaceAsync(nodeManager, ct).ConfigureAwait(false);
_logger.LogInformation("Address space populated for driver {Driver}", driverId); _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.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Core.Hosting;
@@ -15,6 +16,8 @@ public sealed class OpcUaServerService(
NodeBootstrap bootstrap, NodeBootstrap bootstrap,
DriverHost driverHost, DriverHost driverHost,
OpcUaApplicationHost applicationHost, OpcUaApplicationHost applicationHost,
DriverEquipmentContentRegistry equipmentContentRegistry,
IServiceScopeFactory scopeFactory,
ILogger<OpcUaServerService> logger) : BackgroundService ILogger<OpcUaServerService> logger) : BackgroundService
{ {
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -24,6 +27,15 @@ public sealed class OpcUaServerService(
var result = await bootstrap.LoadCurrentGenerationAsync(stoppingToken); var result = await bootstrap.LoadCurrentGenerationAsync(stoppingToken);
logger.LogInformation("Bootstrap complete: source={Source} generation={Gen}", result.Source, result.GenerationId); 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 // PR 17: stand up the OPC UA server + drive discovery per registered driver. Driver
// registration itself (RegisterAsync on DriverHost) happens during an earlier DI // registration itself (RegisterAsync on DriverHost) happens during an earlier DI
// extension once the central config DB query + per-driver factory land; for now the // 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 applicationHost.DisposeAsync();
await driverHost.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<ILocalConfigCache>(_ => new LiteDbConfigCache(options.LocalCachePath));
builder.Services.AddSingleton<DriverHost>(); builder.Services.AddSingleton<DriverHost>();
builder.Services.AddSingleton<NodeBootstrap>(); 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>(); builder.Services.AddHostedService<OpcUaServerService>();
// Central-config DB access for the host-status publisher (LMX follow-up #7). Scoped context // 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; using ZB.MOM.WW.OtOpcUa.Core.Authorization;
namespace ZB.MOM.WW.OtOpcUa.Server.Security; namespace ZB.MOM.WW.OtOpcUa.Server.Security;
/// <summary> /// <summary>
/// Maps a driver-side full reference (e.g. <c>"TestMachine_001/Oven/SetPoint"</c>) to the /// 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 /// <see cref="NodeScope"/> the Phase 6.2 evaluator walks. Supports two modes:
/// returns a cluster-scoped + tag-only scope — the deeper UnsArea / UnsLine / Equipment /// <list type="bullet">
/// path lookup from the live Configuration DB is a Stream C.12 follow-up. /// <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> /// </summary>
/// <remarks> /// <remarks>
/// <para>The flat cluster-level scope is sufficient for v2 GA because Phase 6.2 ACL grants /// <para>The index is pre-loaded by the Server bootstrap against the published generation;
/// at the Cluster scope cascade to every tag below (decision #129 — additive grants). The /// the resolver itself does no live DB access. Resolve is O(1) dictionary lookup on the
/// finer hierarchy only matters when operators want per-area or per-equipment grants; /// hot path; the fallback for unknown fullReference strings produces the same cluster-only
/// those still work for Cluster-level grants, and landing the finer resolution in a /// scope the pre-ADR-001 resolver returned — new tags picked up via driver discovery but
/// follow-up doesn't regress the base security model.</para> /// 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 /// <para>Thread-safety: both constructor paths freeze inputs into immutable state. Callers
/// single instance per DriverNodeManager without locks.</para> /// may cache a single instance per DriverNodeManager without locks. Swap atomically on
/// generation change via the server's publish pipeline.</para>
/// </remarks> /// </remarks>
public sealed class NodeScopeResolver public sealed class NodeScopeResolver
{ {
private readonly string _clusterId; 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) public NodeScopeResolver(string clusterId)
{ {
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId); ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
_clusterId = 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> /// <summary>
/// Resolve a node scope for the given driver-side <paramref name="fullReference"/>. /// Resolve a node scope for the given driver-side <paramref name="fullReference"/>.
/// Phase 1 shape: returns <c>ClusterId</c> + <c>TagId = fullReference</c> only; /// Returns the indexed full-path scope when available; falls back to cluster-only
/// NamespaceId / UnsArea / UnsLine / Equipment stay null. A future resolver will /// (TagId populated only) when the index is absent or the reference isn't indexed.
/// join against the Configuration DB to populate the full path. /// The fallback is the same shape the pre-ADR-001 resolver produced, so the authz
/// evaluator behaves identically for un-indexed references.
/// </summary> /// </summary>
public NodeScope Resolve(string fullReference) public NodeScope Resolve(string fullReference)
{ {
ArgumentException.ThrowIfNullOrWhiteSpace(fullReference); ArgumentException.ThrowIfNullOrWhiteSpace(fullReference);
if (_index is not null && _index.TryGetValue(fullReference, out var indexed))
return indexed;
return new NodeScope return new NodeScope
{ {
ClusterId = _clusterId, 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; await task;
var output = TestConsoleHelper.GetOutput(console); var output = TestConsoleHelper.GetOutput(console);
output.ShouldContain("Subscribed to ns=2;s=TestVar (interval: 2000ms)"); // CLI now prints aggregate form "Subscribed to {count}/{total} nodes (interval: ...)" rather than
output.ShouldContain("Unsubscribed."); // 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.SecurityMode.ShouldBe(SecurityMode.None);
settings.SessionTimeoutSeconds.ShouldBe(60); settings.SessionTimeoutSeconds.ShouldBe(60);
settings.AutoAcceptCertificates.ShouldBeTrue(); settings.AutoAcceptCertificates.ShouldBeTrue();
settings.CertificateStorePath.ShouldContain("LmxOpcUaClient"); settings.CertificateStorePath.ShouldContain("OtOpcUaClient");
settings.CertificateStorePath.ShouldContain("pki"); settings.CertificateStorePath.ShouldContain("pki");
} }

View File

@@ -252,7 +252,7 @@ public class MainWindowViewModelTests
_vm.FailoverUrls.ShouldBeNull(); _vm.FailoverUrls.ShouldBeNull();
_vm.SessionTimeoutSeconds.ShouldBe(60); _vm.SessionTimeoutSeconds.ShouldBe(60);
_vm.AutoAcceptCertificates.ShouldBeTrue(); _vm.AutoAcceptCertificates.ShouldBeTrue();
_vm.CertificateStorePath.ShouldContain("LmxOpcUaClient"); _vm.CertificateStorePath.ShouldContain("OtOpcUaClient");
_vm.CertificateStorePath.ShouldContain("pki"); _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; 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 EncodeValue(AbCipDataType type, int? bitIndex, object? value) => Value = value;
public virtual void Dispose() => Disposed = true; 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] [Fact]
public void Resolve_Leaves_UnsPath_Null_For_Phase1() public void Resolve_Leaves_UnsPath_Null_When_NoIndexSupplied()
{ {
var resolver = new NodeScopeResolver("c-1"); var resolver = new NodeScopeResolver("c-1");
var scope = resolver.Resolve("tag-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.NamespaceId.ShouldBeNull();
scope.UnsAreaId.ShouldBeNull(); scope.UnsAreaId.ShouldBeNull();
scope.UnsLineId.ShouldBeNull(); scope.UnsLineId.ShouldBeNull();
scope.EquipmentId.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] [Fact]
public void Resolve_Throws_OnEmptyFullReference() 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);
}
}
}