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

11 KiB

Phase 4c — cross-driver OPC UA array support (big-bang, all 5 drivers) — design

Status: approved 2026-06-16. Branch feat/stillpending-phase-4c-array-support off master 050164b2.

Goal: an authored equipment tag marked isArray materializes as a 1-D OPC UA array variable node (ValueRank=OneDimension + ArrayDimensions) and its backing driver reads + publishes array values — across Modbus, S7, AbCip, TwinCAT, AbLegacy. Closes stillpending.md §2 "Arrays — discovery hard-wires IsArray:false (arrays surface as scalars)" and "Modbus — String / BitInRegister array decode unsupported".

Scope decision (AskUserQuestion, 2026-06-16): the user chose big-bang — all 5 drivers, and full AbLegacy array read now (not the flag-wired/read-deferred fallback). This is the largest, highest-risk backlog phase; the design front-loads the driver-agnostic plumbing so the five per-driver reads fan out against a stable contract.

Architecture

A small driver-agnostic plumbing core (sink contract → plan metadata → applier → authoring UI), then per-driver array read fanned out across the five disjoint driver projects. Array-ness rides in the existing TagConfig JSON blob as two new keys, isArray (bool) + arrayLength (uint) — NO EF migration, NO new schema column. All new fields default to scalar so existing artifact decode + every existing tag is byte-parity unchanged (zero regression).

The value path is already array-capable: a driver read publishes a bare object? (DriverInstanceActor.AttributeValuePublished) → DriverHostActor.ForwardToMuxOpcUaPublishActorIOpcUaAddressSpaceSink.WriteValueOtOpcUaNodeManager.WriteValue:257 (variable.Value = value;). Assigning a CLR array (int[], string[]) works as-is. The array-ness lives entirely in node creation (EnsureVariable's hard-wired ValueRank=ValueRanks.Scalar).

Hard constraints (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 (array-ness lives in the TagConfig blob); NO wire/proto contract change (the IOpcUaAddressSpaceSink edit is an in-process abstraction over the SDK address space — not a serialized wire/proto contract; same class of change as the Phase-C historianTagname param); NO bUnit (Razor proven by live /run). Use dangerouslyDisableSandbox: true for all rig/docker/network/build/test commands.


§A — Sink contract (the one high-risk contract change)

Files: src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs (interface :74 + NullOpcUaAddressSpaceSink :107), src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs:65, src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs:63, src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs (EnsureVariable:1299, CreateVariable fallback :2049).

EnsureVariable gains two trailing optional params so every existing call site compiles unchanged:

void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName,
    string dataType, bool writable, string? historianTagname = null,
    bool isArray = false, uint? arrayLength = null);

In OtOpcUaNodeManager.EnsureVariable (:1319-1334) and the CreateVariable fallback (:2049):

ValueRank       = isArray ? ValueRanks.OneDimension : ValueRanks.Scalar,
ArrayDimensions = isArray ? new UInt32Collection(new[] { arrayLength ?? 0u }) : null,

ResolveBuiltInDataType (:1355) is unchanged — array elements share the scalar's base DataType; ValueRank + ArrayDimensions carry the dimensionality. arrayLength == 0/null ⇒ unfixed-length array (valid OPC UA; ArrayDimensions[0] = 0 means "unknown").

Value path needs no change (WriteValue:257); a TDD test proves an array-ranked node accepts a CLR array and a client reads it back as an array Variant.

Classification: high-risk (in-process contract, 4 implementers, SDK node semantics).


§B — Plan metadata, byte-parity pair

Files: src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs (the EquipmentTagPlan record + Compose construction + a new ExtractTagArray helper), src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs (BuildEquipmentTagPlans decode), src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs:214 (MaterialiseEquipmentTagsEnsureVariable).

EquipmentTagPlan gains bool IsArray = false, uint? ArrayLength = null as trailing optional record params (existing construction sites + artifact decode produce identical plans; the record's value-equality makes a toggle diff as a change — same mechanism as Historize/IsHistorized).

Phase7Composer adds ExtractTagArray(string? tagConfig) (mirrors the existing ExtractTagFullName / ExtractTagHistorize): parse the raw TagConfig JSON, read isArray (bool) + arrayLength (uint, only when isArray), tolerate malformed JSON → (false, null). DeploymentArtifact.BuildEquipmentTagPlans decodes the same two keys identically (byte-parity is a tested invariant). Phase7Applier.MaterialiseEquipmentTags threads tag.IsArray/tag.ArrayLength into SafeEnsureVariableEnsureVariable.

Classification: high-risk (composer↔artifact byte-parity; a divergence silently corrupts the running address space).


§C — Authoring UI, driver-agnostic (no bUnit)

Files: a new pure TagArrayConfig merge helper under src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/ (mirror the Phase-6 TagHistorizeConfig), src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor (an isArray checkbox + arrayLength numeric, driver-agnostic — alongside the historize controls), and the validator seam the historize control already uses.

The control is driver-agnostic: it authors the isArray/arrayLength keys into the raw TagConfig (preserve-unknown-keys), so it works for both the typed per-driver editors and the raw-JSON fallback. The materializer (§A/§B) reads the keys for rank/dimensions; each driver's equipment-tag resolver (§D) reads them for read-count. Client-side validation: arrayLength > 0 when isArray is set.

Classification: standard. Proven by live /run, not bUnit.


§D — Per-driver array read (parallelizable across disjoint projects)

Each driver's equipment-tag resolver threads arrayLength into its transient tag-def's array-count field; the read path returns a CLR array object; discovery flips the existing IsArray:false hard-wire. DriverAttributeInfo (Core.Abstractions) already carries IsArray + ArrayDim — no DTO change.

Driver Sites Work Class Live
Modbus ModbusDriver.cs:482-484 throw; discovery :276-277 already arrays; resolver numeric arrays already read end-to-end; complete String + BitInRegister array decode (remove throw) + resolver arrayLength → ModbusTagDefinition.ArrayCount small Mac-live (sim 10.100.0.35:5020)
AbCip AbCipDriver.cs:948-949,1002-1003 libplctag native array read → box to object[]; flip IsArray on declared + discovered + UDT-member sites standard unit (fixture down)
TwinCAT TwinCATDriver.cs:370-371; AdsTwinCATClient.cs ADS native array symbol read → box; flip IsArray pre-declared + discovered standard unit (fixture down)
S7 S7Driver.cs:642; read path S7.Net byte-buffer block-read refactor + decode loop + flip flag high-risk unit (fixture down)
AbLegacy AbLegacyDriver.cs:431-438 PCCC multi-element file addressing (N7:0,LEN, up to 256 words) via libplctag array read + flip flag high-risk unit (fixture down)

The five are disjoint projects → implementers dispatch concurrently after §A/§B land the contract. AbLegacy is the single biggest task (addressing-model rework); if it proves intractable mid-execution, surface it rather than thrash (the user chose full read, so we attempt it; the honest fallback would be flag-wired/read-deferred per the FOCAS-4b pattern).


§E — Error handling / robustness

  • Defaults = scalar ⇒ byte-parity, no regression for every existing tag.
  • isArray on a driver tag that reads a scalar (mismatch) → the node stays array-typed and the driver surfaces Bad quality; documented (authoring isArray requires the tag to actually read an array).
  • arrayLength = 0/null ⇒ unfixed-length array (valid OPC UA).
  • Array nodes are always EnsureVariable'd before any subscribe (Phase7Applier materializes ahead of driver subscribe), so the scalar lazy-create CreateVariable fallback is not hit for them — but it is fixed too (defensive).

§F — Testing

Unit: EnsureVariable builds a OneDimension node with the right ArrayDimensions; WriteValue array round-trip (a client reads back an array Variant); EquipmentTagPlan composer↔artifact byte-parity round-trip with isArray/arrayLength set; ExtractTagArray (valid / malformed / absent); TagArrayConfig merge + validate; each driver's array decode/read against its fake backend (Modbus String + BitInRegister arrays; S7 block decode; AbCip/TwinCAT box-to-array; AbLegacy multi-element). NO bUnit (Razor proven by live /run).

Live /run: author a Modbus array equipment tag on the docker-dev rig (login disabled) → confirm the node materializes as an array (browse ValueRank/ArrayDimensions) + an array value flows from the Modbus sim. S7 / AbCip / TwinCAT / AbLegacy are unit-proven (fixtures down on this Mac — honest, operator-gated live).

§G — Out of scope (named deferrals)

  • Array writes (inbound client → device): 1-D read-surface only this phase.
  • Multi-dimensional arrays (ValueRank > 1): 1-D only (DriverAttributeInfo.ArrayDim is 1-D).
  • Array historization (HistoryRead over array nodes).
  • AbCip whole-UDT / TwinCAT Structure-UDT / bit-index RMW writes / OpcUaClient ReadEventsAsync / Galaxy writer item-handle cache / Historian #400 — separate §2 slices, not arrays.

Stale-done stillpending.md §2 items to record (close in the design record, do NOT edit stillpending.md)

  • Modbus Int64/UInt64 node DataType — fixed Phase 4 (bd8fee61).
  • Historian HistoryAggregateType.Total — derived client-side Phase 4 (5e27b5f7).
  • Historian poison alarm events retry indefinitelymaxAttempts dead-letter cap Phase 4 (fcb38014, task #437).

Done criteria

  • dotnet build ZB.MOM.WW.OtOpcUa.slnx clean.
  • Affected suites green: OpcUaServer (EnsureVariable + WriteValue), Runtime (artifact byte-parity), AdminUI.Tests (TagArrayConfig), and all 5 driver test projects (array read/decode).
  • Live /run: a Modbus array tag materializes as an array node + value flows on the rig; the other 4 drivers unit-proven (operator-gated live).
  • Subagent-driven, per-task review by classification, final integration review, then merge + push.