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

18 KiB
Raw Blame History

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_roundtripsEnsureVariable(..., 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:

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):

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):

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):

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):

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).