Compare commits
8 Commits
docs-refre
...
adr-001-eq
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ddc13b7fc | ||
|
|
97e1f55bbb | ||
| cb2a375548 | |||
|
|
33b87a3aa4 | ||
| 2391de7f79 | |||
|
|
f9bc301c33 | ||
|
|
12d748c4f3 | ||
| e9b1d107ab |
@@ -87,13 +87,14 @@ The server supports non-transparent warm/hot redundancy via the `Redundancy` sec
|
||||
|
||||
## LDAP Authentication
|
||||
|
||||
The server uses LDAP-based user authentication via the `Authentication.Ldap` section in `appsettings.json`. When enabled, credentials are validated by LDAP bind against a GLAuth server (installed at `C:\publish\glauth\`), and LDAP group membership maps to OPC UA permissions: `ReadOnly` (browse/read), `WriteOperate` (write FreeAccess/Operate attributes), `WriteTune` (write Tune attributes), `WriteConfigure` (write Configure attributes), `AlarmAck` (alarm acknowledgment). `LdapAuthenticationProvider` implements both `IUserAuthenticationProvider` and `IRoleProvider`. See `docs/Security.md` for the full guide and `C:\publish\glauth\auth.md` for LDAP user/group reference.
|
||||
The server uses LDAP-based user authentication via the `Authentication.Ldap` section in `appsettings.json`. When enabled, credentials are validated by LDAP bind against a GLAuth server (installed at `C:\publish\glauth\`), and LDAP group membership maps to OPC UA permissions: `ReadOnly` (browse/read), `WriteOperate` (write FreeAccess/Operate attributes), `WriteTune` (write Tune attributes), `WriteConfigure` (write Configure attributes), `AlarmAck` (alarm acknowledgment). `LdapUserAuthenticator` (`src/ZB.MOM.WW.OtOpcUa.Server/Security/LdapUserAuthenticator.cs`) implements `IUserAuthenticator`. See `docs/Security.md` for the full guide and `C:\publish\glauth\auth.md` for LDAP user/group reference.
|
||||
|
||||
## Library Preferences
|
||||
|
||||
- **Logging**: Serilog with rolling daily file sink
|
||||
- **Unit tests**: xUnit + Shouldly for assertions
|
||||
- **Service hosting**: TopShelf (Windows service install/uninstall/run as console)
|
||||
- **Service hosting (Server, Admin)**: .NET generic host with `AddWindowsService` (decision #30 — replaced TopShelf in v2; see `src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs`)
|
||||
- **Service hosting (Galaxy.Host)**: plain console app wrapped by NSSM (`.NET Framework 4.8 x86` — required by MXAccess COM bitness)
|
||||
- **OPC UA**: OPC Foundation UA .NET Standard stack (https://github.com/opcfoundation/ua-.netstandard) — NuGet: `OPCFoundation.NetStandard.Opc.Ua.Server`
|
||||
|
||||
## OPC UA .NET Standard Documentation
|
||||
|
||||
@@ -14,7 +14,7 @@ dotnet build
|
||||
dotnet run -- <command> [options]
|
||||
```
|
||||
|
||||
The executable name is still `lmxopcua-cli` — a residual from the pre-v2 rename (`Program.cs:SetExecutableName`). Scripts + operator muscle memory depend on the name; flipping it to `otopcua-cli` is a follow-up that also needs to move the client-side PKI store folder (<code>{LocalAppData}/LmxOpcUaClient/pki/</code> — used by the shared client for its application certificate) so trust relationships survive the rename.
|
||||
The executable name is `otopcua-cli`. Dev boxes carrying a pre-task-#208 install may still have the legacy `{LocalAppData}/LmxOpcUaClient/` folder on disk; on first launch of any post-#208 CLI or UI build, `ClientStoragePaths` (`src/ZB.MOM.WW.OtOpcUa.Client.Shared/ClientStoragePaths.cs`) migrates it to `{LocalAppData}/OtOpcUaClient/` automatically so trusted certificates + saved settings survive the rename.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -46,7 +46,7 @@ All commands accept these options:
|
||||
When `-U` and `-P` are provided, the shared service passes a `UserIdentity(username, password)` to the OPC UA session. Without credentials, anonymous identity is used.
|
||||
|
||||
```bash
|
||||
lmxopcua-cli write -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -v 42 -U operator -P op123
|
||||
otopcua-cli write -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -v 42 -U operator -P op123
|
||||
```
|
||||
|
||||
### Failover
|
||||
@@ -54,20 +54,20 @@ lmxopcua-cli write -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -v 42 -U opera
|
||||
When `-F` is provided, the shared service tries the primary URL first, then each failover URL in order. For long-running commands (`subscribe`, `alarms`), the service monitors the session via keep-alive and automatically reconnects to the next available server on failure.
|
||||
|
||||
```bash
|
||||
lmxopcua-cli connect -u opc.tcp://localhost:4840/OtOpcUa -F opc.tcp://localhost:4841/OtOpcUa
|
||||
otopcua-cli connect -u opc.tcp://localhost:4840/OtOpcUa -F opc.tcp://localhost:4841/OtOpcUa
|
||||
```
|
||||
|
||||
### Transport Security
|
||||
|
||||
When `sign` or `encrypt` is specified, the shared service:
|
||||
|
||||
1. Ensures a client application certificate exists under `{LocalAppData}/LmxOpcUaClient/pki/` (auto-created if missing)
|
||||
1. Ensures a client application certificate exists under `{LocalAppData}/OtOpcUaClient/pki/` (auto-created if missing; pre-rename `LmxOpcUaClient/` is migrated in place on first launch)
|
||||
2. Discovers server endpoints and selects one matching the requested security mode
|
||||
3. Prefers `Basic256Sha256` when multiple matching endpoints exist
|
||||
4. Fails with a clear error if no matching endpoint is found
|
||||
|
||||
```bash
|
||||
lmxopcua-cli browse -u opc.tcp://localhost:4840/OtOpcUa -S encrypt -U admin -P secret -r -d 2
|
||||
otopcua-cli browse -u opc.tcp://localhost:4840/OtOpcUa -S encrypt -U admin -P secret -r -d 2
|
||||
```
|
||||
|
||||
### Verbose Logging
|
||||
@@ -81,7 +81,7 @@ The `--verbose` flag switches Serilog output from `Warning` to `Debug` level, sh
|
||||
Tests connectivity to an OPC UA server. Creates a session, prints connection metadata, and disconnects.
|
||||
|
||||
```bash
|
||||
lmxopcua-cli connect -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123
|
||||
otopcua-cli connect -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123
|
||||
```
|
||||
|
||||
Output:
|
||||
@@ -99,7 +99,7 @@ Connection successful.
|
||||
Reads the current value of a single node and prints the value, status code, and timestamps.
|
||||
|
||||
```bash
|
||||
lmxopcua-cli read -u opc.tcp://localhost:4840/OtOpcUa -n "ns=3;s=DEV.ScanState" -U admin -P admin123
|
||||
otopcua-cli read -u opc.tcp://localhost:4840/OtOpcUa -n "ns=3;s=DEV.ScanState" -U admin -P admin123
|
||||
```
|
||||
|
||||
| Flag | Description |
|
||||
@@ -121,7 +121,7 @@ Server Time: 2026-03-30T19:58:38.0971257Z
|
||||
Writes a value to a node. The shared service reads the current value first to determine the target data type, then converts the supplied string value using `ValueConverter.ConvertValue()`.
|
||||
|
||||
```bash
|
||||
lmxopcua-cli write -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -v 42
|
||||
otopcua-cli write -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -v 42
|
||||
```
|
||||
|
||||
| Flag | Description |
|
||||
@@ -135,10 +135,10 @@ Browses the OPC UA address space starting from the Objects folder or a specified
|
||||
|
||||
```bash
|
||||
# Browse top-level Objects folder
|
||||
lmxopcua-cli browse -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123
|
||||
otopcua-cli browse -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123
|
||||
|
||||
# Browse a specific node recursively to depth 3
|
||||
lmxopcua-cli browse -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123 -r -d 3 -n "ns=3;s=ZB"
|
||||
otopcua-cli browse -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123 -r -d 3 -n "ns=3;s=ZB"
|
||||
```
|
||||
|
||||
| Flag | Description |
|
||||
@@ -152,7 +152,7 @@ lmxopcua-cli browse -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123 -r
|
||||
Monitors a node for value changes using OPC UA subscriptions. Prints each data change notification with timestamp, value, and status code. Runs until Ctrl+C, then unsubscribes and disconnects cleanly.
|
||||
|
||||
```bash
|
||||
lmxopcua-cli subscribe -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -i 500
|
||||
otopcua-cli subscribe -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -i 500
|
||||
```
|
||||
|
||||
| Flag | Description |
|
||||
@@ -166,12 +166,12 @@ Reads historical data from a node. Supports raw history reads and aggregate (pro
|
||||
|
||||
```bash
|
||||
# Raw history
|
||||
lmxopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa \
|
||||
otopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa \
|
||||
-n "ns=1;s=TestMachine_001.TestHistoryValue" \
|
||||
--start "2026-03-25" --end "2026-03-30"
|
||||
|
||||
# Aggregate: 1-hour average
|
||||
lmxopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa \
|
||||
otopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa \
|
||||
-n "ns=1;s=TestMachine_001.TestHistoryValue" \
|
||||
--start "2026-03-25" --end "2026-03-30" \
|
||||
--aggregate Average --interval 3600000
|
||||
@@ -203,10 +203,10 @@ Subscribes to alarm events on a node. Prints structured alarm output including s
|
||||
|
||||
```bash
|
||||
# Subscribe to alarm events on the Server node
|
||||
lmxopcua-cli alarms -u opc.tcp://localhost:4840/OtOpcUa
|
||||
otopcua-cli alarms -u opc.tcp://localhost:4840/OtOpcUa
|
||||
|
||||
# Subscribe to a specific source node with condition refresh
|
||||
lmxopcua-cli alarms -u opc.tcp://localhost:4840/OtOpcUa \
|
||||
otopcua-cli alarms -u opc.tcp://localhost:4840/OtOpcUa \
|
||||
-n "ns=1;s=TestMachine_001" --refresh
|
||||
```
|
||||
|
||||
@@ -221,7 +221,7 @@ lmxopcua-cli alarms -u opc.tcp://localhost:4840/OtOpcUa \
|
||||
Reads the OPC UA redundancy state from a server: redundancy mode, service level, server URIs, and application URI.
|
||||
|
||||
```bash
|
||||
lmxopcua-cli redundancy -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123
|
||||
otopcua-cli redundancy -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123
|
||||
```
|
||||
|
||||
Example output:
|
||||
|
||||
@@ -65,7 +65,7 @@ The top bar provides the endpoint URL, Connect, and Disconnect buttons. The **Co
|
||||
|
||||
### Settings Persistence
|
||||
|
||||
Connection settings are saved to `{LocalAppData}/LmxOpcUaClient/settings.json` after each successful connection and on window close. The folder name is a residual from the pre-v2 rename (the `Client.Shared` session factory still calls itself `LmxOpcUaClient` at `OpcUaClientService.cs:428`); renaming to `OtOpcUaClient` is a follow-up that needs a migration shim so existing users don't lose their settings on upgrade. The settings are reloaded on next launch, including:
|
||||
Connection settings are saved to `{LocalAppData}/OtOpcUaClient/settings.json` after each successful connection and on window close. Dev boxes upgrading from a pre-task-#208 build still have the legacy `LmxOpcUaClient/` folder on disk; `ClientStoragePaths` in `Client.Shared` moves it to the canonical path on first launch so existing trusted certs + saved settings persist without operator action. The settings are reloaded on next launch, including:
|
||||
|
||||
- All connection parameters
|
||||
- Active subscription node IDs (restored after reconnection)
|
||||
|
||||
248
docs/v2/implementation/adr-001-equipment-node-walker.md
Normal file
248
docs/v2/implementation/adr-001-equipment-node-walker.md
Normal 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.
|
||||
108
docs/v2/implementation/exit-gate-phase-2-closed.md
Normal file
108
docs/v2/implementation/exit-gate-phase-2-closed.md
Normal 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.1–A.3 | ✅ Complete | PR 1 |
|
||||
| B — Driver.Galaxy.Host | §B.1–B.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.1–C.4 | ✅ Complete — all 9 capability interfaces, supervisor (Backoff + CircuitBreaker + HeartbeatMonitor), subscription push frames | PR 1 + PR 4 |
|
||||
| D — Retire legacy Host | §D.1–D.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.1–E.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.
|
||||
@@ -1,5 +1,11 @@
|
||||
# Phase 2 Final Exit Gate (2026-04-18)
|
||||
|
||||
> **⚠️ Superseded by [`exit-gate-phase-2-closed.md`](exit-gate-phase-2-closed.md) (2026-04-20).**
|
||||
> This doc captures the snapshot at PR 2 merge — when the four `High` + `Medium` findings
|
||||
> in the adversarial review were still OPEN and Historian port + alarm subsystem were still
|
||||
> deferred. All of those closed subsequently (PR 4 + PR 12 + PR 13 + PR 14 + PR 61). Kept
|
||||
> as historical evidence; consult the close-out doc for current Phase 2 status.
|
||||
|
||||
> Supersedes `phase-2-partial-exit-evidence.md` and `exit-gate-phase-2.md`. Captures the
|
||||
> as-built state at the close of Phase 2 work delivered across two PRs.
|
||||
|
||||
|
||||
@@ -736,7 +736,7 @@ Each step leaves the system runnable. The generic extraction is effectively free
|
||||
6. **Wire `Server`** — bootstrap from Configuration using an instance-bound credential (cert/gMSA/SQL login), fail fast if the credential is rejected, register drivers, start Core.
|
||||
7. **Scaffold `Admin`** — Blazor Server app with: instance + credential management, draft/publish/rollback generation workflow (diff viewer, "publish to fleet", per-instance override), and core CRUD for drivers/devices/tags. Driver-specific config screens deferred to later phases.
|
||||
|
||||
**Phase 2 — Galaxy driver (prove the refactor)**
|
||||
**Phase 2 — Galaxy driver (prove the refactor) — ✅ CLOSED 2026-04-20** (see [`implementation/exit-gate-phase-2-closed.md`](implementation/exit-gate-phase-2-closed.md))
|
||||
8. **Build `Galaxy.Shared`** — .NET Standard 2.0 IPC message contracts
|
||||
9. **Build `Galaxy.Host`** — .NET 4.8 x86 process hosting MxAccessBridge, GalaxyRepository, alarms, HDA with IPC server
|
||||
10. **Build `Galaxy.Proxy`** — .NET 10 in-process proxy implementing IDriver interfaces, forwarding over IPC
|
||||
|
||||
@@ -9,7 +9,7 @@ return await new CliApplicationBuilder()
|
||||
if (type.IsSubclassOf(typeof(CommandBase))) return Activator.CreateInstance(type, CommandBase.DefaultFactory)!;
|
||||
return Activator.CreateInstance(type)!;
|
||||
})
|
||||
.SetExecutableName("lmxopcua-cli")
|
||||
.SetDescription("LmxOpcUa CLI - command-line client for the LmxOpcUa OPC UA server")
|
||||
.SetExecutableName("otopcua-cli")
|
||||
.SetDescription("OtOpcUa CLI - command-line client for the OtOpcUa OPC UA server")
|
||||
.Build()
|
||||
.RunAsync(args);
|
||||
@@ -18,8 +18,8 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi
|
||||
|
||||
var config = new ApplicationConfiguration
|
||||
{
|
||||
ApplicationName = "LmxOpcUaClient",
|
||||
ApplicationUri = "urn:localhost:LmxOpcUaClient",
|
||||
ApplicationName = "OtOpcUaClient",
|
||||
ApplicationUri = "urn:localhost:OtOpcUaClient",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
SecurityConfiguration = new SecurityConfiguration
|
||||
{
|
||||
@@ -60,7 +60,7 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi
|
||||
{
|
||||
var app = new ApplicationInstance
|
||||
{
|
||||
ApplicationName = "LmxOpcUaClient",
|
||||
ApplicationName = "OtOpcUaClient",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
ApplicationConfiguration = config
|
||||
};
|
||||
|
||||
90
src/ZB.MOM.WW.OtOpcUa.Client.Shared/ClientStoragePaths.cs
Normal file
90
src/ZB.MOM.WW.OtOpcUa.Client.Shared/ClientStoragePaths.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,11 +41,11 @@ public sealed class ConnectionSettings
|
||||
public bool AutoAcceptCertificates { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Path to the certificate store. Defaults to a subdirectory under LocalApplicationData.
|
||||
/// Path to the certificate store. Defaults to a subdirectory under LocalApplicationData
|
||||
/// resolved via <see cref="ClientStoragePaths"/> so the one-shot legacy-folder migration
|
||||
/// runs before the path is returned.
|
||||
/// </summary>
|
||||
public string CertificateStorePath { get; set; } = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LmxOpcUaClient", "pki");
|
||||
public string CertificateStorePath { get; set; } = ClientStoragePaths.GetPkiPath();
|
||||
|
||||
/// <summary>
|
||||
/// Validates the settings and throws if any required values are missing or invalid.
|
||||
|
||||
@@ -425,7 +425,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
: new UserIdentity();
|
||||
|
||||
var sessionTimeoutMs = (uint)(settings.SessionTimeoutSeconds * 1000);
|
||||
return await _sessionFactory.CreateSessionAsync(config, endpoint, "LmxOpcUaClient", sessionTimeoutMs, identity,
|
||||
return await _sessionFactory.CreateSessionAsync(config, endpoint, "OtOpcUaClient", sessionTimeoutMs, identity,
|
||||
ct);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.UI.Services;
|
||||
|
||||
@@ -7,9 +8,9 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI.Services;
|
||||
/// </summary>
|
||||
public sealed class JsonSettingsService : ISettingsService
|
||||
{
|
||||
private static readonly string SettingsDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LmxOpcUaClient");
|
||||
// ClientStoragePaths.GetRoot runs the one-shot legacy-folder migration so pre-#208
|
||||
// developer boxes pick up their existing settings.json on first launch post-rename.
|
||||
private static readonly string SettingsDir = ClientStoragePaths.GetRoot();
|
||||
|
||||
private static readonly string SettingsPath = Path.Combine(SettingsDir, "settings.json");
|
||||
|
||||
|
||||
@@ -21,9 +21,7 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
|
||||
[ObservableProperty] private bool _autoAcceptCertificates = true;
|
||||
|
||||
[ObservableProperty] private string _certificateStorePath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LmxOpcUaClient", "pki");
|
||||
[ObservableProperty] private string _certificateStorePath = ClientStoragePaths.GetPkiPath();
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(ConnectCommand))]
|
||||
|
||||
@@ -101,7 +101,8 @@ public class SubscribeCommandTests
|
||||
await task;
|
||||
|
||||
var output = TestConsoleHelper.GetOutput(console);
|
||||
output.ShouldContain("Subscribed to ns=2;s=TestVar (interval: 2000ms)");
|
||||
output.ShouldContain("Unsubscribed.");
|
||||
// CLI now prints aggregate form "Subscribed to {count}/{total} nodes (interval: ...)" rather than
|
||||
// the single-node form the original test asserted — the command supports multi-node now.
|
||||
output.ShouldContain("Subscribed to 1/1 nodes (interval: 2000ms)");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ public class ConnectionSettingsTests
|
||||
settings.SecurityMode.ShouldBe(SecurityMode.None);
|
||||
settings.SessionTimeoutSeconds.ShouldBe(60);
|
||||
settings.AutoAcceptCertificates.ShouldBeTrue();
|
||||
settings.CertificateStorePath.ShouldContain("LmxOpcUaClient");
|
||||
settings.CertificateStorePath.ShouldContain("OtOpcUaClient");
|
||||
settings.CertificateStorePath.ShouldContain("pki");
|
||||
}
|
||||
|
||||
|
||||
@@ -252,7 +252,7 @@ public class MainWindowViewModelTests
|
||||
_vm.FailoverUrls.ShouldBeNull();
|
||||
_vm.SessionTimeoutSeconds.ShouldBe(60);
|
||||
_vm.AutoAcceptCertificates.ShouldBeTrue();
|
||||
_vm.CertificateStorePath.ShouldContain("LmxOpcUaClient");
|
||||
_vm.CertificateStorePath.ShouldContain("OtOpcUaClient");
|
||||
_vm.CertificateStorePath.ShouldContain("pki");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user