Files
lmxopcua/docs/plans/2026-06-16-stillpending-phase-4c-array-support.md
T

376 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 4c — cross-driver OPC UA array support — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to implement this plan task-by-task.
**Goal:** an authored equipment tag marked `isArray` materializes as a 1-D OPC UA array variable
node and its backing driver reads + publishes array values — across Modbus, S7, AbCip, TwinCAT,
AbLegacy.
**Architecture:** driver-agnostic plumbing core (sink contract → plan metadata → applier → authoring
UI) first, then per-driver array read fanned out across the 5 disjoint driver projects. Array-ness
rides in the existing `TagConfig` JSON blob (`isArray`/`arrayLength`) — NO EF migration. All new
fields default to scalar ⇒ byte-parity unchanged for every existing tag.
**Tech Stack:** .NET 10, OPC UA Foundation UA-.NETStandard (`BaseDataVariableState`, `ValueRanks`,
`UInt32Collection`), Akka.NET, EF Core (read-only here), Blazor Server (AdminUI), S7.Net, libplctag
(AbCip/AbLegacy), TwinCAT ADS.
**Design:** `docs/plans/2026-06-16-stillpending-phase-4c-array-support-design.md` (committed
`efccd8d1`). Branch `feat/stillpending-phase-4c-array-support` off master `050164b2`.
**Hard rules (every task):** stage by explicit path, never `git add .`; never stage `sql_login.txt`
/ `src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/` / `pending.md` / `current.md` /
`docker-dev/docker-compose.yml` / `stillpending.md`; no force-push / no `--no-verify`; NO EF
migration; NO wire/proto contract change (the `IOpcUaAddressSpaceSink` edit is an in-process
abstraction — fine); NO bUnit; `dangerouslyDisableSandbox: true` for all build/test/rig commands.
Concurrent sibling implementers touch disjoint projects — build ONLY your own project(s); a
half-written sibling driver breaking the whole-solution build is NOT your bug.
**Dependency graph:** `T1 → T2 → T3`; then `{T4 ∥ T5 ∥ T6 ∥ T7 ∥ T8 ∥ T9}` (all after T1) →
`T10 → T11 → T12`.
---
## Task 1: Sink contract — `EnsureVariable` array params (§A)
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (foundation)
**Files:**
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs:74` (interface) + `:107` (`NullOpcUaAddressSpaceSink`)
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs:65`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs:63`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` (`EnsureVariable:1299-1349`, `CreateVariable` fallback near `:2049`)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/` (the node-manager test class that covers `EnsureVariable`/`WriteValue`)
**Step 1 — failing tests.** In the OpcUaServer test project, add:
- `EnsureVariable_isArray_creates_OneDimension_node_with_ArrayDimensions` — call
`EnsureVariable(node, parent, "arr", "Int32", writable:false, isArray:true, arrayLength:8)`; assert
the created `BaseDataVariableState` has `ValueRank == ValueRanks.OneDimension` and
`ArrayDimensions` == `[8]`.
- `EnsureVariable_scalar_default_unchanged` — existing scalar call still yields
`ValueRank == ValueRanks.Scalar` and null/empty `ArrayDimensions`.
- `WriteValue_array_roundtrips``EnsureVariable(..., isArray:true, arrayLength:3)` then
`WriteValue(node, new int[]{1,2,3}, Good, ts)`; assert the node's `Value` is the `int[]` and (if
the harness exposes it) reads back as an array Variant.
**Step 2 — run, verify fail.** `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter FullyQualifiedName~EnsureVariable` (compile error — new params). `dangerouslyDisableSandbox:true`.
**Step 3 — implement.** Add the two trailing optional params to all four implementers:
```csharp
void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName,
string dataType, bool writable, string? historianTagname = null,
bool isArray = false, uint? arrayLength = null);
```
`DeferredAddressSpaceSink` delegates them through; `NullOpcUaAddressSpaceSink` ignores them. In
`OtOpcUaNodeManager.EnsureVariable` change the node initializer (`:1327`):
```csharp
ValueRank = isArray ? ValueRanks.OneDimension : ValueRanks.Scalar,
ArrayDimensions = isArray ? new UInt32Collection(new[] { arrayLength ?? 0u }) : null,
```
Apply the same two lines in the `CreateVariable` fallback (near `:2049`). `ResolveBuiltInDataType`
unchanged. `WriteValue` (`:257`) unchanged.
**Step 4 — run, verify pass.** Same filter. Then the full OpcUaServer suite.
**Step 5 — commit.** `git add` the 4 sink files + node manager + the test file; `git commit -m "feat(opcua): EnsureVariable array params (ValueRank=OneDimension + ArrayDimensions)"`.
---
## Task 2: Plan metadata — `EquipmentTagPlan.IsArray/ArrayLength` + composer + applier (§B)
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (blockedBy T1)
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs` (record `:87-98`; `Compose` construction `:354-366`; add `ExtractTagArray` mirroring `ExtractTagHistorize:519`)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs:214` (`SafeEnsureVariable` call + signature)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/` (composer test class)
**Step 1 — failing tests.** Composer test: a Tag whose `TagConfig` has `"isArray":true,"arrayLength":16`
produces an `EquipmentTagPlan` with `IsArray==true`, `ArrayLength==16`; absent keys ⇒ `false/null`;
malformed JSON ⇒ `false/null`. A changed-array-flag diff is a `ChangedEquipmentTags` entry (record
equality).
**Step 2 — verify fail.**
**Step 3 — implement.** Extend the record (trailing optional, after `HistorianTagname`):
```csharp
bool IsHistorized = false,
string? HistorianTagname = null,
bool IsArray = false,
uint? ArrayLength = null);
```
Add `ExtractTagArray(string? tagConfig)` modeled exactly on `ExtractTagHistorize` (`:519`): parse
`TagConfig`, read `isArray` (bool; absent/non-bool/non-object/blank/malformed ⇒ false) and
`arrayLength` (uint; only when `isArray` and a number). In `Compose` (`:354`):
```csharp
var (isArray, arrayLength) = ExtractTagArray(t.TagConfig);
return new EquipmentTagPlan(
...,
IsHistorized: isHistorized,
HistorianTagname: historianTagname,
IsArray: isArray,
ArrayLength: arrayLength);
```
In `Phase7Applier` (`:214` + the `SafeEnsureVariable` helper `:312`): thread `tag.IsArray`,
`tag.ArrayLength` into `EnsureVariable`. (The other `SafeEnsureVariable` caller at `:267`
VirtualTags/non-equipment — passes `isArray:false`.)
**Step 4 — verify pass.** Composer suite.
**Step 5 — commit.** `git add` Phase7Composer + Phase7Applier + tests; `git commit -m "feat(opcua): EquipmentTagPlan IsArray/ArrayLength + composer ExtractTagArray + applier wire-in"`.
---
## Task 3: Artifact decode — byte-parity mirror (§B)
**Classification:** high-risk
**Estimated implement time:** ~4 min
**Parallelizable with:** none (blockedBy T2)
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs` (`BuildEquipmentTagPlans:404-452`; add `ExtractTagArray` mirroring its `ExtractTagHistorize:700`)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/` (DeploymentArtifact tests)
**Step 1 — failing test.** `BuildEquipmentTagPlans` decodes `isArray`/`arrayLength` from the
serialized tag JSON identically to the composer; a **byte-parity round-trip** test (compose → snapshot
JSON → decode) yields `IsArray`/`ArrayLength` equal on both sides for a tag with the keys set.
**Step 2 — verify fail.**
**Step 3 — implement.** Add `ExtractTagArray` modeled on this file's `ExtractTagHistorize` (`:700`).
In `BuildEquipmentTagPlans` (`:440`):
```csharp
var (isArray, arrayLength) = ExtractTagArray(tagConfig);
result.Add(new EquipmentTagPlan(
...,
IsHistorized: isHistorized,
HistorianTagname: historianTagname,
IsArray: isArray,
ArrayLength: arrayLength));
```
The two `ExtractTagArray` copies (composer + artifact) MUST parse identically (byte-parity invariant).
**Step 4 — verify pass.** DeploymentArtifact suite + the byte-parity test.
**Step 5 — commit.** `git add` DeploymentArtifact + tests; `git commit -m "feat(runtime): decode IsArray/ArrayLength byte-parity in DeploymentArtifact"`.
---
## Task 4: Authoring UI — driver-agnostic `isArray`/`arrayLength` (§C)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** T5, T6, T7, T8, T9 (blockedBy T1)
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagArrayConfig.cs` (mirror `TagHistorizeConfig.cs`)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor` (add the control beside the historize control)
- Test: `tests/.../AdminUI.Tests/Uns/` (the test class covering `TagHistorizeConfig`)
**Step 1 — failing tests.** `TagArrayConfig` merge: reading a `TagConfig` with `isArray`/`arrayLength`
populates the model; writing back preserves unknown keys + emits the two keys; clearing `isArray`
removes `arrayLength`. Validate: `arrayLength > 0` required when `isArray`.
**Step 2 — verify fail.**
**Step 3 — implement.** `TagArrayConfig` = pure merge helper exactly like `TagHistorizeConfig`
(`ParseOrNew` → read/write `isArray` bool + `arrayLength` uint via `TagConfigJson`, preserve-unknown).
In `TagModal.razor`, add an `IsArray` checkbox + an `ArrayLength` numeric (shown when checked),
driver-agnostic (alongside the historize fields, applied to the raw `TagConfig` for both typed
editors and the raw-JSON fallback). Client-side validation message when `isArray && arrayLength<=0`.
**Step 4 — verify pass.** AdminUI.Tests (cores only; NO bUnit).
**Step 5 — commit.** `git add` TagArrayConfig + TagModal.razor + tests; `git commit -m "feat(adminui): driver-agnostic isArray/arrayLength Tag-modal control"`.
---
## Task 5: Modbus — String/BitInRegister array decode + resolver (§D)
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** T4, T6, T7, T8, T9 (blockedBy T1)
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs` (array decode `:482-484` throw; the equipment-tag resolver path that builds a transient `ModbusTagDefinition` — thread `arrayLength → ArrayCount`)
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/`
**Context:** numeric arrays already decode (`:454-481` build `int[]`/`ulong[]`/`float[]`/`double[]`).
Only String + BitInRegister throw (`:482-484`).
**Step 1 — failing tests.** Array decode for `String` (N contiguous string blocks → `string[]`) and
`BitInRegister` (a register block → per-bit `bool[]`); the equipment-tag resolver maps `arrayLength`
into `ModbusTagDefinition.ArrayCount` so an array-config equipment tag reads an array.
**Step 2 — verify fail.**
**Step 3 — implement.** Replace the `:482-484` `default: throw` with `String` + `BitInRegister`
cases (loop over `count`, decode each element with the existing scalar decoder, return the typed
array). Thread `arrayLength` through the Modbus equipment-tag resolver into `ArrayCount`.
**Step 4 — verify pass.** `dotnet test ...Driver.Modbus.Tests` (`dangerouslyDisableSandbox:true`).
**Step 5 — commit.** `git add` ModbusDriver.cs + tests; `git commit -m "feat(modbus): String + BitInRegister array decode + equipment-tag arrayLength"`.
---
## Task 6: AbCip — libplctag array read (§D)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** T4, T5, T7, T8, T9 (blockedBy T1)
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs` (`IsArray:false` sites `:948-949`, `:1002-1003`; read path; resolver)
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/`
**Step 1 — failing tests.** Discovery flips `IsArray` for an array-typed atomic tag + UDT member; the
read path returns a CLR array (`object[]`/typed) for an array tag (against the fake libplctag
backend); resolver threads `arrayLength`.
**Step 2 — verify fail.**
**Step 3 — implement.** libplctag reads array elements natively — read `arrayLength` elements, box to
the element-typed array; flip the `IsArray:false` discovery hard-wires using the element count.
**Step 4 — verify pass.** `...Driver.AbCip.Tests`.
**Step 5 — commit.** `git commit -m "feat(abcip): 1-D array read via libplctag + IsArray discovery"`.
---
## Task 7: TwinCAT — ADS array symbol read (§D)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** T4, T5, T6, T8, T9 (blockedBy T1)
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs:370-371`; `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs` (read path)
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/`
**Step 1 — failing tests.** Discovery flips `IsArray` for array symbols (pre-declared + discovered);
the ADS read returns a CLR array (against the fake ADS client); resolver threads `arrayLength`.
**Step 2 — verify fail.**
**Step 3 — implement.** ADS reads array symbols natively — read + box to the element-typed array;
flip the `IsArray:false` discovery hard-wires from the symbol's array metadata.
**Step 4 — verify pass.** `...Driver.TwinCAT.Tests`.
**Step 5 — commit.** `git commit -m "feat(twincat): 1-D array symbol read via ADS + IsArray discovery"`.
---
## Task 8: S7 — block-read array (§D)
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** T4, T5, T6, T7, T9 (blockedBy T1)
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs:642` (IsArray) + the read path (block read + decode loop) + resolver
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/`
**Step 1 — failing tests.** Discovery flips `IsArray` for an array tag; the read path reads
`arrayLength` contiguous elements (block read) and decodes them to a CLR array (against the fake S7
backend); resolver threads `arrayLength`.
**Step 2 — verify fail.**
**Step 3 — implement.** Read a contiguous block sized `arrayLength × elementBytes` from the tag's
start address, decode each element with the existing per-type decoder into a typed array; flip the
`:642` `IsArray:false`. Keep the scalar path unchanged when `arrayLength` is null/1.
**Step 4 — verify pass.** `...Driver.S7.Tests`.
**Step 5 — commit.** `git commit -m "feat(s7): 1-D array block read + decode loop + IsArray discovery"`.
---
## Task 9: AbLegacy — PCCC multi-element array read (§D, heaviest)
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** T4, T5, T6, T7, T8 (blockedBy T1)
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs:431-438` (IsArray hard-wire) + the read path + resolver
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/`
**Context:** a PCCC data file (e.g. `N7`) is inherently an array of up to 256 words; the driver
addresses one word at a time today. Array support = multi-element file read via libplctag
(`N7:0` reading `arrayLength` words).
**Step 1 — failing tests.** Discovery flips `IsArray` for a multi-element file tag; the read path
reads `arrayLength` words from the file base element and returns a CLR array (against the fake
libplctag backend); resolver threads `arrayLength`. Cap at the PCCC file max (256).
**Step 2 — verify fail.**
**Step 3 — implement.** libplctag reads PCCC file arrays natively — read `arrayLength` elements from
the base file address, box to the element-typed array; flip the `:431-438` hard-wire. If the
addressing rework proves materially larger than estimated, STOP and surface it to the parent (do not
thrash) — the honest fallback is flag-wired + a clear `BadNotSupported` read (FOCAS-4b pattern), but
attempt the full read first.
**Step 4 — verify pass.** `...Driver.AbLegacy.Tests`.
**Step 5 — commit.** `git commit -m "feat(ablegacy): PCCC multi-element file array read + IsArray discovery"`.
---
## Task 10: Docs + bookkeeping
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none (blockedBy T1T9)
**Files:**
- Modify: `docs/drivers/Modbus.md`, `docs/drivers/Galaxy.md`-style notes for S7/AbCip/AbLegacy/TwinCAT array support (whichever driver docs exist), a short array section (the `isArray`/`arrayLength` `TagConfig` keys + the per-driver coverage matrix + the read-only/1-D/no-write scope).
- Modify: `docs/plans/2026-06-16-stillpending-phase-4c-array-support.md.tasks.json` (bookkeeping at close-out).
Document: the two new `TagConfig` keys; the per-driver coverage matrix (all 5 read; live-verify status
per driver); record the 3 stale-done `§2` items (Modbus-Int64-node-type, Historian-Total,
Historian-poison-dead-letter — all Phase 4); the named out-of-scope deferrals (array writes,
multi-dim, array historization). Do NOT edit `stillpending.md`.
**Commit:** `git add` the doc files; `git commit -m "docs(phase4c): cross-driver array support + TagConfig keys + coverage matrix"`.
---
## Task 11: Full build + test + final integration review
**Classification:** standard
**Estimated implement time:** ~6 min
**Parallelizable with:** none (blockedBy T1T10)
`dotnet build ZB.MOM.WW.OtOpcUa.slnx` clean (`dangerouslyDisableSandbox:true`); run OpcUaServer +
Runtime + AdminUI.Tests + all 5 driver test suites green. Dispatch a **final integration reviewer**
over `git diff 050164b2..HEAD` checking: byte-parity (composer↔artifact `ExtractTagArray` identical);
defaults preserve scalar (no regression); the sink contract change is additive-only across all 4
implementers; no EF migration / no wire-proto change / no bUnit; each driver's array read is gated on
`isArray`/`arrayLength` and the scalar path is untouched. Apply any Critical/Important findings via a
fresh implementer.
---
## Task 12: Live `/run` acceptance
**Classification:** standard
**Estimated implement time:** ~6 min
**Parallelizable with:** none (blockedBy T11)
Rebuild docker-dev central-1 from the branch (`docker compose -f docker-dev/docker-compose.yml build central-1`
then `up -d --force-recreate central-1 traefik`, `dangerouslyDisableSandbox:true`). Drive AdminUI
`http://localhost:9200` (login disabled) via the Chrome tools: author a **Modbus array** equipment tag
(`isArray` + `arrayLength`) on the seeded `MAIN-modbus-eq` → deploy → browse via Client.CLI and
confirm the node is an array (`ValueRank`/`ArrayDimensions`) + an array value flows from the Modbus
sim (`10.100.0.35:5020`). S7 / AbCip / TwinCAT / AbLegacy are **unit-proven** (fixtures down — honest,
operator-gated live). Record results; then finish the branch (merge to master + push).