18 KiB
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,CreateVariablefallback near:2049) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/(the node-manager test class that coversEnsureVariable/WriteValue)
Step 1 — failing tests. In the OpcUaServer test project, add:
EnsureVariable_isArray_creates_OneDimension_node_with_ArrayDimensions— callEnsureVariable(node, parent, "arr", "Int32", writable:false, isArray:true, arrayLength:8); assert the createdBaseDataVariableStatehasValueRank == ValueRanks.OneDimensionandArrayDimensions==[8].EnsureVariable_scalar_default_unchanged— existing scalar call still yieldsValueRank == ValueRanks.Scalarand null/emptyArrayDimensions.WriteValue_array_roundtrips—EnsureVariable(..., isArray:true, arrayLength:3)thenWriteValue(node, new int[]{1,2,3}, Good, ts); assert the node'sValueis theint[]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;Composeconstruction:354-366; addExtractTagArraymirroringExtractTagHistorize:519) - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs:214(SafeEnsureVariablecall + 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; addExtractTagArraymirroring itsExtractTagHistorize: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(mirrorTagHistorizeConfig.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 coveringTagHistorizeConfig)
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-484throw; the equipment-tag resolver path that builds a transientModbusTagDefinition— threadarrayLength → 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:falsesites: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 T1–T9)
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 (theisArray/arrayLengthTagConfigkeys + 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 T1–T10)
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).