docs(phase4c): design — cross-driver OPC UA array support (big-bang, all 5 drivers)
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user