diff --git a/docs/plans/2026-06-16-stillpending-phase-4c-array-support-design.md b/docs/plans/2026-06-16-stillpending-phase-4c-array-support-design.md new file mode 100644 index 00000000..738cefce --- /dev/null +++ b/docs/plans/2026-06-16-stillpending-phase-4c-array-support-design.md @@ -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.