264 lines
14 KiB
Markdown
264 lines
14 KiB
Markdown
# OtOpcUa v3 cutover — `nsu=` hardening + browse UX (phase 2) Implementation Plan
|
||
|
||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||
|
||
**Goal:** Ship the pure ScadaBridge-side slice of the OtOpcUa v3 dual-namespace cutover (Gitea [#14](https://gitea.dohertylan.com/dohertj2/ScadaBridge/issues/14)) — nudge operators toward durable `nsu=` bindings and tidy the two-subtree browse UX — without a live v3 server.
|
||
|
||
**Architecture:** Add one pure string helper in Commons (`OpcUaReferenceForm.IsDurable`) that flags bare `ns=<index>` references (which the v3 raw/uns split makes ambiguous — see scope doc §4 item A). Surface it as a non-blocking authoring warning in the node-browser picker and fix the manual-entry placeholder. Sweep the `ns=`-only doc examples to `nsu=`. This is the D-2-recommended **warn, don't reject** posture: existing `ns=` bindings keep resolving (the `OpcUaNodeReference.Resolve` seam is unchanged and still accepts both forms), so nothing breaks on the current v2 rig.
|
||
|
||
**Tech Stack:** C#/.NET 10, xunit, Blazor Server (Bootstrap), `TreatWarningsAsErrors`.
|
||
|
||
**Scope boundary (read first):** This plan is **phase 2** of the scope doc `docs/plans/2026-07-23-otopcua-v3-dual-namespace-cutover-scope.md` — items **D, E, F** only. Items **A** (re-author legacy bindings), **B** (native-alarm dedup), and **C** (UNS-bound alarm routing) are **deferred — they require a re-seeded live OtOpcUa v3.0 rig** and live alarm-fan-out validation (scope doc §5 phases 3–5, §7). Do NOT attempt them here. Decisions folded in: **D-1 = ingest both subtrees** (already recorded); **D-2 = warn on bare `ns=`** (this plan); **D-3 = re-author, no migration code** (nothing to build).
|
||
|
||
---
|
||
|
||
### Task 1: Commons — `OpcUaReferenceForm.IsDurable` durability helper
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** Task 3
|
||
|
||
**Files:**
|
||
- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/DataConnections/OpcUaReferenceForm.cs`
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/DataConnections/OpcUaReferenceFormTests.cs`
|
||
|
||
A **pure string** helper — no `Opc.Ua` dependency (Commons has none, and CentralUI references only Commons). It answers one question for the authoring UI: *is this reference index-durable, or a bare server-namespace index we should warn about?* A bare `ns=<n>` with n ≥ 1 is exactly the form the v3 raw/uns namespace split makes ambiguous (scope doc §4 item A). `nsu=`, spec-fixed `ns=0`, and short forms with no namespace prefix (`i=85`, `s=Foo`) are durable and must not warn. Whitespace/empty returns `true` (nothing typed → nothing to warn about).
|
||
|
||
**Step 1: Write the failing tests**
|
||
|
||
```csharp
|
||
using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
|
||
|
||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.DataConnections;
|
||
|
||
public class OpcUaReferenceFormTests
|
||
{
|
||
[Theory]
|
||
// durable → true
|
||
[InlineData("nsu=https://zb.com/otopcua/raw;s=Line1.Pump.Speed", true)]
|
||
[InlineData("nsu=https://zb.com/otopcua/uns;s=AreaA/Line1/Pump/Speed", true)]
|
||
[InlineData("NSU=https://x;s=y", true)] // case-insensitive prefix
|
||
[InlineData("ns=0;i=85", true)] // spec-fixed namespace 0
|
||
[InlineData("i=85", true)] // short form, implicit ns0
|
||
[InlineData("s=Devices.Pump1", true)] // no namespace prefix
|
||
[InlineData("", true)] // nothing typed
|
||
[InlineData(" ", true)]
|
||
[InlineData(null, true)]
|
||
// bare server-namespace index → false (warn)
|
||
[InlineData("ns=2;s=MyDevice.Temperature", false)]
|
||
[InlineData("ns=1;i=1001", false)]
|
||
[InlineData("ns=10;s=x", false)]
|
||
[InlineData(" ns=3;s=x ", false)] // trimmed before inspection
|
||
public void IsDurable_classifies_reference_forms(string? reference, bool expected)
|
||
{
|
||
Assert.Equal(expected, OpcUaReferenceForm.IsDurable(reference));
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: Run to verify it fails**
|
||
|
||
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/ZB.MOM.WW.ScadaBridge.Commons.Tests.csproj --filter OpcUaReferenceFormTests`
|
||
Expected: FAIL — `OpcUaReferenceForm` does not exist (compile error).
|
||
|
||
**Step 3: Write the minimal implementation**
|
||
|
||
```csharp
|
||
using System.Globalization;
|
||
|
||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
|
||
|
||
/// <summary>
|
||
/// Pure, protocol-package-free classification of an OPC UA node-reference string's
|
||
/// <i>form</i> — used by authoring UI to nudge operators toward index-durable bindings.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// A bare <c>ns=<index>;…</c> reference hard-codes a namespace <i>index</i>, which is
|
||
/// only meaningful relative to one server's namespace table. The OtOpcUa v3.0 raw/uns
|
||
/// split makes this actively dangerous: v2's sole custom namespace and v3's <c>raw</c>
|
||
/// tree both land at <c>ns=2</c>, so a stored <c>ns=2;s=…</c> resolves without error but
|
||
/// silently now means a raw-tree node (scope doc §4 item A). The durable form is
|
||
/// <c>nsu=<uri>;…</c>, resolved against the live namespace table at use time by
|
||
/// <c>OpcUaNodeReference.Resolve</c>. This helper only classifies the string; it does not
|
||
/// parse or resolve NodeIds (that stays in the DataConnectionLayer seam, which owns the
|
||
/// <c>Opc.Ua</c> dependency).
|
||
/// </remarks>
|
||
public static class OpcUaReferenceForm
|
||
{
|
||
/// <summary>
|
||
/// True when <paramref name="reference"/> is index-durable — the durable
|
||
/// <c>nsu=<uri>;…</c> form, spec-fixed <c>ns=0</c>, a short form with no namespace
|
||
/// prefix, or empty/whitespace (nothing to warn about). False only for a bare
|
||
/// server-namespace index (<c>ns=<n>;…</c> with n ≥ 1).
|
||
/// </summary>
|
||
public static bool IsDurable(string? reference)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(reference))
|
||
return true;
|
||
|
||
var trimmed = reference.Trim();
|
||
|
||
// nsu=<uri>;… is the durable form (and its "ns" prefix must be checked before the
|
||
// bare "ns=" branch below, since "nsu=" also starts with "ns").
|
||
if (trimmed.StartsWith("nsu=", StringComparison.OrdinalIgnoreCase))
|
||
return true;
|
||
|
||
// Not a bare namespace-index reference at all → short forms (i=85, s=Foo) that
|
||
// imply namespace 0, which is spec-fixed and already durable.
|
||
if (!trimmed.StartsWith("ns=", StringComparison.OrdinalIgnoreCase))
|
||
return true;
|
||
|
||
// ns=<digits>;… — parse the index; ns=0 is spec-fixed (durable), n ≥ 1 is bare.
|
||
var semicolon = trimmed.IndexOf(';');
|
||
var digits = semicolon >= 0 ? trimmed[3..semicolon] : trimmed[3..];
|
||
if (int.TryParse(digits, NumberStyles.None, CultureInfo.InvariantCulture, out var index))
|
||
return index == 0;
|
||
|
||
// Malformed "ns=" with non-numeric index — not a recognisable bare index; leave
|
||
// the real parse/reject to OpcUaNodeReference.Resolve. Don't warn on it here.
|
||
return true;
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 4: Run to verify it passes**
|
||
|
||
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/ZB.MOM.WW.ScadaBridge.Commons.Tests.csproj --filter OpcUaReferenceFormTests`
|
||
Expected: PASS (all theory rows).
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/ZB.MOM.WW.ScadaBridge.Commons/Types/DataConnections/OpcUaReferenceForm.cs \
|
||
tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/DataConnections/OpcUaReferenceFormTests.cs
|
||
git commit -m "feat(dcl): add OpcUaReferenceForm.IsDurable — flags bare ns= bindings (v3 cutover #14)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Central UI — durable-binding nudge in the node-browser picker (items D + E)
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** none (uses the Task 1 helper)
|
||
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/NodeBrowserDialog.razor`
|
||
|
||
Two UX changes, both non-blocking (D-2 = warn, not reject):
|
||
1. Manual-entry **placeholder** `ns=2;s=...` → `nsu=<namespace-uri>;s=...` (stop nudging the index form).
|
||
2. A muted inline **warning** below the manual-entry input when the typed reference is a bare `ns=` index (`!OpcUaReferenceForm.IsDurable(_manualNodeId)`), explaining that index bindings can silently re-point after a server namespace change and to prefer the browse tree / `nsu=` form. Safe for every protocol that uses this dialog: MxGateway node ids carry no `ns=` prefix, so `IsDurable` returns `true` and the warning never shows.
|
||
|
||
> Not in scope here: the picker already renders whatever root nodes the server exposes, so the two v3 subtrees (`raw`/`uns`) appear as sibling roots with **no code change**. Tuning the search `maxDepth: 6` for the deeper raw hierarchy is deferred — it needs a live v3 rig to calibrate (scope doc §5 phase 3).
|
||
|
||
**Step 1: Add the using and update the placeholder**
|
||
|
||
At the top of the file, add to the existing `@using` block:
|
||
|
||
```razor
|
||
@using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections
|
||
```
|
||
|
||
Change the manual-node-id input (currently line ~92) placeholder:
|
||
|
||
```razor
|
||
<input class="form-control" @bind="_manualNodeId" @bind:event="oninput"
|
||
placeholder="nsu=<namespace-uri>;s=..." />
|
||
```
|
||
|
||
(Note the added `@bind:event="oninput"` so the warning below updates as the operator types.)
|
||
|
||
**Step 2: Add the inline durability warning**
|
||
|
||
Immediately after the manual-entry `input-group` `</div>` (line ~94, before the `modal-footer`), add:
|
||
|
||
```razor
|
||
@if (!OpcUaReferenceForm.IsDurable(_manualNodeId))
|
||
{
|
||
<small class="text-warning-emphasis d-block mt-1" data-test="ns-index-warning">
|
||
This is a bare namespace-<em>index</em> binding. Indexes can silently re-point after a
|
||
server namespace change — prefer selecting from the tree above (it stores the durable
|
||
<code>nsu=<uri>;…</code> form).
|
||
</small>
|
||
}
|
||
```
|
||
|
||
**Step 3: Build to verify it compiles**
|
||
|
||
Run: `dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj`
|
||
Expected: Build succeeded, 0 warnings (project is `TreatWarningsAsErrors`).
|
||
|
||
**Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/NodeBrowserDialog.razor
|
||
git commit -m "feat(ui): warn on bare ns= node bindings in the browse picker (v3 cutover #14)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Docs — sweep `ns=` examples to `nsu=` (item F)
|
||
|
||
**Classification:** trivial
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** Task 1
|
||
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs:15`
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/IBrowsableDataConnection.cs:36`
|
||
- Modify: `docs/requirements/Component-DataConnectionLayer.md`
|
||
- Modify: `../scadaproj/CLAUDE.md` (umbrella index — CLAUDE.md propagation rule)
|
||
|
||
Only the sole-example `ns=2;s=…` comments get updated. **Leave `OpcUaNodeReference.cs` alone** — it deliberately documents *both* forms and explains why. Do not touch any `ns=` string in code/tests that is an actual binding value or assertion.
|
||
|
||
**Step 1: Update the two code-comment examples**
|
||
|
||
`OpcUaDataConnection.cs:15` — change:
|
||
```
|
||
/// - TagPath → NodeId (e.g., "ns=2;s=MyDevice.Temperature")
|
||
```
|
||
to:
|
||
```
|
||
/// - TagPath → NodeId (durable form, e.g. "nsu=https://server/ns;s=MyDevice.Temperature";
|
||
/// the legacy "ns=2;s=..." index form is still accepted — see OpcUaNodeReference)
|
||
```
|
||
|
||
`IBrowsableDataConnection.cs:36` — change the `<param name="NodeId">` example from `"ns=2;s=Devices.Pump1.Speed"` to `"nsu=https://server/ns;s=Devices.Pump1.Speed"`.
|
||
|
||
**Step 2: Update the component doc**
|
||
|
||
In `docs/requirements/Component-DataConnectionLayer.md`: update any `ns=<index>` example bindings to the `nsu=<uri>` durable form, and add a short note (cross-referencing `docs/plans/2026-07-23-otopcua-v3-dual-namespace-cutover-scope.md`) that OtOpcUa v3.0 splits its address space into `raw` + `uns` namespaces, that ScadaBridge is namespace-URI-durable via `OpcUaNodeReference`, and that the picker nudges operators to the `nsu=` form. First read the file to place the note in the OPC UA / node-reference section.
|
||
|
||
**Step 3: Update the umbrella index**
|
||
|
||
In `../scadaproj/CLAUDE.md`, find the ScadaBridge entry's OtOpcUa relationship line and note that ScadaBridge is v3-`nsu=`-durable and the #14 dual-namespace cutover is in progress (phase 2 shipped: `nsu=` authoring nudge + browse UX; alarm-fan-out live-gate deferred to a v3 rig). Read the surrounding lines first to match the index's style; keep it to one or two lines.
|
||
|
||
**Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs \
|
||
src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/IBrowsableDataConnection.cs \
|
||
docs/requirements/Component-DataConnectionLayer.md
|
||
git commit -m "docs(dcl): sweep ns= examples to durable nsu= form (v3 cutover #14)"
|
||
# umbrella index is a separate repo — commit it there
|
||
git -C ../scadaproj add CLAUDE.md && git -C ../scadaproj commit -m "docs: note ScadaBridge OtOpcUa v3 nsu= cutover phase 2 (#14)"
|
||
```
|
||
|
||
---
|
||
|
||
## Deferred — requires a live OtOpcUa v3.0 rig (NOT in this plan)
|
||
|
||
Tracked in the scope doc (§5 phases 3–5, §7). Do not attempt without a re-seeded v3 server:
|
||
|
||
- **Item A** — re-author existing dev/test bindings across both subtrees via the picker (data, greenfield; D-3 = no migration code).
|
||
- **Item B** — native-alarm dedup across the raw+uns notifier fan-out; add `ConditionId` dedup **only if** the live server double-delivers.
|
||
- **Item C** — UNS-bound alarm routing: a v3 condition's `SourceName` is always the RawPath, so a UNS-bound source won't `StartsWith`-match — resolve UNS→Raw via the browseable `Organizes` reference. Build against the real `SourceName`/`SourceNode` payload captured on the rig.
|
||
|
||
## Verification (whole-plan, after Task 3)
|
||
|
||
```bash
|
||
dotnet build ZB.MOM.WW.ScadaBridge.slnx
|
||
dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/ZB.MOM.WW.ScadaBridge.Commons.Tests.csproj
|
||
```
|
||
Expected: build 0/0; Commons tests green (incl. `OpcUaReferenceFormTests`). No EF migration is added (scope doc §7 — `OpcUaEndpointConfig` has no namespace field).
|