docs(phase4c): design — cross-driver OPC UA array support (big-bang, all 5 drivers)

This commit is contained in:
Joseph Doherty
2026-06-16 21:06:59 -04:00
parent 050164b21f
commit efccd8d1a6
@@ -0,0 +1,190 @@
# 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:
```csharp
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`):
```csharp
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.
- `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 indefinitely** — `maxAttempts` 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.