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.ForwardToMux →
OpcUaPublishActor → IOpcUaAddressSpaceSink.WriteValue → OtOpcUaNodeManager.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
(MaterialiseEquipmentTags → EnsureVariable).
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 SafeEnsureVariable → EnsureVariable.
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.
isArrayon a driver tag that reads a scalar (mismatch) → the node stays array-typed and the driver surfaces Bad quality; documented (authoringisArrayrequires 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-createCreateVariablefallback 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.ArrayDimis 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§2slices, 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 indefinitely —
maxAttemptsdead-letter cap Phase 4 (fcb38014, task#437).
Done criteria
dotnet build ZB.MOM.WW.OtOpcUa.slnxclean.- 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.