376 lines
18 KiB
Markdown
376 lines
18 KiB
Markdown
# Phase 4c — cross-driver OPC UA array support — Implementation Plan
|
||
|
||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to implement this plan task-by-task.
|
||
|
||
**Goal:** an authored equipment tag marked `isArray` materializes as a 1-D OPC UA array variable
|
||
node and its backing driver reads + publishes array values — across Modbus, S7, AbCip, TwinCAT,
|
||
AbLegacy.
|
||
|
||
**Architecture:** driver-agnostic plumbing core (sink contract → plan metadata → applier → authoring
|
||
UI) first, then per-driver array read fanned out across the 5 disjoint driver projects. Array-ness
|
||
rides in the existing `TagConfig` JSON blob (`isArray`/`arrayLength`) — NO EF migration. All new
|
||
fields default to scalar ⇒ byte-parity unchanged for every existing tag.
|
||
|
||
**Tech Stack:** .NET 10, OPC UA Foundation UA-.NETStandard (`BaseDataVariableState`, `ValueRanks`,
|
||
`UInt32Collection`), Akka.NET, EF Core (read-only here), Blazor Server (AdminUI), S7.Net, libplctag
|
||
(AbCip/AbLegacy), TwinCAT ADS.
|
||
|
||
**Design:** `docs/plans/2026-06-16-stillpending-phase-4c-array-support-design.md` (committed
|
||
`efccd8d1`). Branch `feat/stillpending-phase-4c-array-support` off master `050164b2`.
|
||
|
||
**Hard rules (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; NO wire/proto contract change (the `IOpcUaAddressSpaceSink` edit is an in-process
|
||
abstraction — fine); NO bUnit; `dangerouslyDisableSandbox: true` for all build/test/rig commands.
|
||
Concurrent sibling implementers touch disjoint projects — build ONLY your own project(s); a
|
||
half-written sibling driver breaking the whole-solution build is NOT your bug.
|
||
|
||
**Dependency graph:** `T1 → T2 → T3`; then `{T4 ∥ T5 ∥ T6 ∥ T7 ∥ T8 ∥ T9}` (all after T1) →
|
||
`T10 → T11 → T12`.
|
||
|
||
---
|
||
|
||
## Task 1: Sink contract — `EnsureVariable` array params (§A)
|
||
|
||
**Classification:** high-risk
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none (foundation)
|
||
|
||
**Files:**
|
||
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs:74` (interface) + `:107` (`NullOpcUaAddressSpaceSink`)
|
||
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs:65`
|
||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs:63`
|
||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` (`EnsureVariable:1299-1349`, `CreateVariable` fallback near `:2049`)
|
||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/` (the node-manager test class that covers `EnsureVariable`/`WriteValue`)
|
||
|
||
**Step 1 — failing tests.** In the OpcUaServer test project, add:
|
||
- `EnsureVariable_isArray_creates_OneDimension_node_with_ArrayDimensions` — call
|
||
`EnsureVariable(node, parent, "arr", "Int32", writable:false, isArray:true, arrayLength:8)`; assert
|
||
the created `BaseDataVariableState` has `ValueRank == ValueRanks.OneDimension` and
|
||
`ArrayDimensions` == `[8]`.
|
||
- `EnsureVariable_scalar_default_unchanged` — existing scalar call still yields
|
||
`ValueRank == ValueRanks.Scalar` and null/empty `ArrayDimensions`.
|
||
- `WriteValue_array_roundtrips` — `EnsureVariable(..., isArray:true, arrayLength:3)` then
|
||
`WriteValue(node, new int[]{1,2,3}, Good, ts)`; assert the node's `Value` is the `int[]` and (if
|
||
the harness exposes it) reads back as an array Variant.
|
||
|
||
**Step 2 — run, verify fail.** `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter FullyQualifiedName~EnsureVariable` (compile error — new params). `dangerouslyDisableSandbox:true`.
|
||
|
||
**Step 3 — implement.** Add the two trailing optional params to all four implementers:
|
||
```csharp
|
||
void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName,
|
||
string dataType, bool writable, string? historianTagname = null,
|
||
bool isArray = false, uint? arrayLength = null);
|
||
```
|
||
`DeferredAddressSpaceSink` delegates them through; `NullOpcUaAddressSpaceSink` ignores them. In
|
||
`OtOpcUaNodeManager.EnsureVariable` change the node initializer (`:1327`):
|
||
```csharp
|
||
ValueRank = isArray ? ValueRanks.OneDimension : ValueRanks.Scalar,
|
||
ArrayDimensions = isArray ? new UInt32Collection(new[] { arrayLength ?? 0u }) : null,
|
||
```
|
||
Apply the same two lines in the `CreateVariable` fallback (near `:2049`). `ResolveBuiltInDataType`
|
||
unchanged. `WriteValue` (`:257`) unchanged.
|
||
|
||
**Step 4 — run, verify pass.** Same filter. Then the full OpcUaServer suite.
|
||
|
||
**Step 5 — commit.** `git add` the 4 sink files + node manager + the test file; `git commit -m "feat(opcua): EnsureVariable array params (ValueRank=OneDimension + ArrayDimensions)"`.
|
||
|
||
---
|
||
|
||
## Task 2: Plan metadata — `EquipmentTagPlan.IsArray/ArrayLength` + composer + applier (§B)
|
||
|
||
**Classification:** high-risk
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none (blockedBy T1)
|
||
|
||
**Files:**
|
||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs` (record `:87-98`; `Compose` construction `:354-366`; add `ExtractTagArray` mirroring `ExtractTagHistorize:519`)
|
||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs:214` (`SafeEnsureVariable` call + signature)
|
||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/` (composer test class)
|
||
|
||
**Step 1 — failing tests.** Composer test: a Tag whose `TagConfig` has `"isArray":true,"arrayLength":16`
|
||
produces an `EquipmentTagPlan` with `IsArray==true`, `ArrayLength==16`; absent keys ⇒ `false/null`;
|
||
malformed JSON ⇒ `false/null`. A changed-array-flag diff is a `ChangedEquipmentTags` entry (record
|
||
equality).
|
||
|
||
**Step 2 — verify fail.**
|
||
|
||
**Step 3 — implement.** Extend the record (trailing optional, after `HistorianTagname`):
|
||
```csharp
|
||
bool IsHistorized = false,
|
||
string? HistorianTagname = null,
|
||
bool IsArray = false,
|
||
uint? ArrayLength = null);
|
||
```
|
||
Add `ExtractTagArray(string? tagConfig)` modeled exactly on `ExtractTagHistorize` (`:519`): parse
|
||
`TagConfig`, read `isArray` (bool; absent/non-bool/non-object/blank/malformed ⇒ false) and
|
||
`arrayLength` (uint; only when `isArray` and a number). In `Compose` (`:354`):
|
||
```csharp
|
||
var (isArray, arrayLength) = ExtractTagArray(t.TagConfig);
|
||
return new EquipmentTagPlan(
|
||
...,
|
||
IsHistorized: isHistorized,
|
||
HistorianTagname: historianTagname,
|
||
IsArray: isArray,
|
||
ArrayLength: arrayLength);
|
||
```
|
||
In `Phase7Applier` (`:214` + the `SafeEnsureVariable` helper `:312`): thread `tag.IsArray`,
|
||
`tag.ArrayLength` into `EnsureVariable`. (The other `SafeEnsureVariable` caller at `:267` —
|
||
VirtualTags/non-equipment — passes `isArray:false`.)
|
||
|
||
**Step 4 — verify pass.** Composer suite.
|
||
|
||
**Step 5 — commit.** `git add` Phase7Composer + Phase7Applier + tests; `git commit -m "feat(opcua): EquipmentTagPlan IsArray/ArrayLength + composer ExtractTagArray + applier wire-in"`.
|
||
|
||
---
|
||
|
||
## Task 3: Artifact decode — byte-parity mirror (§B)
|
||
|
||
**Classification:** high-risk
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** none (blockedBy T2)
|
||
|
||
**Files:**
|
||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs` (`BuildEquipmentTagPlans:404-452`; add `ExtractTagArray` mirroring its `ExtractTagHistorize:700`)
|
||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/` (DeploymentArtifact tests)
|
||
|
||
**Step 1 — failing test.** `BuildEquipmentTagPlans` decodes `isArray`/`arrayLength` from the
|
||
serialized tag JSON identically to the composer; a **byte-parity round-trip** test (compose → snapshot
|
||
JSON → decode) yields `IsArray`/`ArrayLength` equal on both sides for a tag with the keys set.
|
||
|
||
**Step 2 — verify fail.**
|
||
|
||
**Step 3 — implement.** Add `ExtractTagArray` modeled on this file's `ExtractTagHistorize` (`:700`).
|
||
In `BuildEquipmentTagPlans` (`:440`):
|
||
```csharp
|
||
var (isArray, arrayLength) = ExtractTagArray(tagConfig);
|
||
result.Add(new EquipmentTagPlan(
|
||
...,
|
||
IsHistorized: isHistorized,
|
||
HistorianTagname: historianTagname,
|
||
IsArray: isArray,
|
||
ArrayLength: arrayLength));
|
||
```
|
||
The two `ExtractTagArray` copies (composer + artifact) MUST parse identically (byte-parity invariant).
|
||
|
||
**Step 4 — verify pass.** DeploymentArtifact suite + the byte-parity test.
|
||
|
||
**Step 5 — commit.** `git add` DeploymentArtifact + tests; `git commit -m "feat(runtime): decode IsArray/ArrayLength byte-parity in DeploymentArtifact"`.
|
||
|
||
---
|
||
|
||
## Task 4: Authoring UI — driver-agnostic `isArray`/`arrayLength` (§C)
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** T5, T6, T7, T8, T9 (blockedBy T1)
|
||
|
||
**Files:**
|
||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagArrayConfig.cs` (mirror `TagHistorizeConfig.cs`)
|
||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor` (add the control beside the historize control)
|
||
- Test: `tests/.../AdminUI.Tests/Uns/` (the test class covering `TagHistorizeConfig`)
|
||
|
||
**Step 1 — failing tests.** `TagArrayConfig` merge: reading a `TagConfig` with `isArray`/`arrayLength`
|
||
populates the model; writing back preserves unknown keys + emits the two keys; clearing `isArray`
|
||
removes `arrayLength`. Validate: `arrayLength > 0` required when `isArray`.
|
||
|
||
**Step 2 — verify fail.**
|
||
|
||
**Step 3 — implement.** `TagArrayConfig` = pure merge helper exactly like `TagHistorizeConfig`
|
||
(`ParseOrNew` → read/write `isArray` bool + `arrayLength` uint via `TagConfigJson`, preserve-unknown).
|
||
In `TagModal.razor`, add an `IsArray` checkbox + an `ArrayLength` numeric (shown when checked),
|
||
driver-agnostic (alongside the historize fields, applied to the raw `TagConfig` for both typed
|
||
editors and the raw-JSON fallback). Client-side validation message when `isArray && arrayLength<=0`.
|
||
|
||
**Step 4 — verify pass.** AdminUI.Tests (cores only; NO bUnit).
|
||
|
||
**Step 5 — commit.** `git add` TagArrayConfig + TagModal.razor + tests; `git commit -m "feat(adminui): driver-agnostic isArray/arrayLength Tag-modal control"`.
|
||
|
||
---
|
||
|
||
## Task 5: Modbus — String/BitInRegister array decode + resolver (§D)
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** T4, T6, T7, T8, T9 (blockedBy T1)
|
||
|
||
**Files:**
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs` (array decode `:482-484` throw; the equipment-tag resolver path that builds a transient `ModbusTagDefinition` — thread `arrayLength → ArrayCount`)
|
||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/`
|
||
|
||
**Context:** numeric arrays already decode (`:454-481` build `int[]`/`ulong[]`/`float[]`/`double[]`).
|
||
Only String + BitInRegister throw (`:482-484`).
|
||
|
||
**Step 1 — failing tests.** Array decode for `String` (N contiguous string blocks → `string[]`) and
|
||
`BitInRegister` (a register block → per-bit `bool[]`); the equipment-tag resolver maps `arrayLength`
|
||
into `ModbusTagDefinition.ArrayCount` so an array-config equipment tag reads an array.
|
||
|
||
**Step 2 — verify fail.**
|
||
|
||
**Step 3 — implement.** Replace the `:482-484` `default: throw` with `String` + `BitInRegister`
|
||
cases (loop over `count`, decode each element with the existing scalar decoder, return the typed
|
||
array). Thread `arrayLength` through the Modbus equipment-tag resolver into `ArrayCount`.
|
||
|
||
**Step 4 — verify pass.** `dotnet test ...Driver.Modbus.Tests` (`dangerouslyDisableSandbox:true`).
|
||
|
||
**Step 5 — commit.** `git add` ModbusDriver.cs + tests; `git commit -m "feat(modbus): String + BitInRegister array decode + equipment-tag arrayLength"`.
|
||
|
||
---
|
||
|
||
## Task 6: AbCip — libplctag array read (§D)
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** T4, T5, T7, T8, T9 (blockedBy T1)
|
||
|
||
**Files:**
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs` (`IsArray:false` sites `:948-949`, `:1002-1003`; read path; resolver)
|
||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/`
|
||
|
||
**Step 1 — failing tests.** Discovery flips `IsArray` for an array-typed atomic tag + UDT member; the
|
||
read path returns a CLR array (`object[]`/typed) for an array tag (against the fake libplctag
|
||
backend); resolver threads `arrayLength`.
|
||
|
||
**Step 2 — verify fail.**
|
||
|
||
**Step 3 — implement.** libplctag reads array elements natively — read `arrayLength` elements, box to
|
||
the element-typed array; flip the `IsArray:false` discovery hard-wires using the element count.
|
||
|
||
**Step 4 — verify pass.** `...Driver.AbCip.Tests`.
|
||
|
||
**Step 5 — commit.** `git commit -m "feat(abcip): 1-D array read via libplctag + IsArray discovery"`.
|
||
|
||
---
|
||
|
||
## Task 7: TwinCAT — ADS array symbol read (§D)
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** T4, T5, T6, T8, T9 (blockedBy T1)
|
||
|
||
**Files:**
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs:370-371`; `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs` (read path)
|
||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/`
|
||
|
||
**Step 1 — failing tests.** Discovery flips `IsArray` for array symbols (pre-declared + discovered);
|
||
the ADS read returns a CLR array (against the fake ADS client); resolver threads `arrayLength`.
|
||
|
||
**Step 2 — verify fail.**
|
||
|
||
**Step 3 — implement.** ADS reads array symbols natively — read + box to the element-typed array;
|
||
flip the `IsArray:false` discovery hard-wires from the symbol's array metadata.
|
||
|
||
**Step 4 — verify pass.** `...Driver.TwinCAT.Tests`.
|
||
|
||
**Step 5 — commit.** `git commit -m "feat(twincat): 1-D array symbol read via ADS + IsArray discovery"`.
|
||
|
||
---
|
||
|
||
## Task 8: S7 — block-read array (§D)
|
||
|
||
**Classification:** high-risk
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** T4, T5, T6, T7, T9 (blockedBy T1)
|
||
|
||
**Files:**
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs:642` (IsArray) + the read path (block read + decode loop) + resolver
|
||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/`
|
||
|
||
**Step 1 — failing tests.** Discovery flips `IsArray` for an array tag; the read path reads
|
||
`arrayLength` contiguous elements (block read) and decodes them to a CLR array (against the fake S7
|
||
backend); resolver threads `arrayLength`.
|
||
|
||
**Step 2 — verify fail.**
|
||
|
||
**Step 3 — implement.** Read a contiguous block sized `arrayLength × elementBytes` from the tag's
|
||
start address, decode each element with the existing per-type decoder into a typed array; flip the
|
||
`:642` `IsArray:false`. Keep the scalar path unchanged when `arrayLength` is null/1.
|
||
|
||
**Step 4 — verify pass.** `...Driver.S7.Tests`.
|
||
|
||
**Step 5 — commit.** `git commit -m "feat(s7): 1-D array block read + decode loop + IsArray discovery"`.
|
||
|
||
---
|
||
|
||
## Task 9: AbLegacy — PCCC multi-element array read (§D, heaviest)
|
||
|
||
**Classification:** high-risk
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** T4, T5, T6, T7, T8 (blockedBy T1)
|
||
|
||
**Files:**
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs:431-438` (IsArray hard-wire) + the read path + resolver
|
||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/`
|
||
|
||
**Context:** a PCCC data file (e.g. `N7`) is inherently an array of up to 256 words; the driver
|
||
addresses one word at a time today. Array support = multi-element file read via libplctag
|
||
(`N7:0` reading `arrayLength` words).
|
||
|
||
**Step 1 — failing tests.** Discovery flips `IsArray` for a multi-element file tag; the read path
|
||
reads `arrayLength` words from the file base element and returns a CLR array (against the fake
|
||
libplctag backend); resolver threads `arrayLength`. Cap at the PCCC file max (256).
|
||
|
||
**Step 2 — verify fail.**
|
||
|
||
**Step 3 — implement.** libplctag reads PCCC file arrays natively — read `arrayLength` elements from
|
||
the base file address, box to the element-typed array; flip the `:431-438` hard-wire. If the
|
||
addressing rework proves materially larger than estimated, STOP and surface it to the parent (do not
|
||
thrash) — the honest fallback is flag-wired + a clear `BadNotSupported` read (FOCAS-4b pattern), but
|
||
attempt the full read first.
|
||
|
||
**Step 4 — verify pass.** `...Driver.AbLegacy.Tests`.
|
||
|
||
**Step 5 — commit.** `git commit -m "feat(ablegacy): PCCC multi-element file array read + IsArray discovery"`.
|
||
|
||
---
|
||
|
||
## Task 10: Docs + bookkeeping
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** none (blockedBy T1–T9)
|
||
|
||
**Files:**
|
||
- Modify: `docs/drivers/Modbus.md`, `docs/drivers/Galaxy.md`-style notes for S7/AbCip/AbLegacy/TwinCAT array support (whichever driver docs exist), a short array section (the `isArray`/`arrayLength` `TagConfig` keys + the per-driver coverage matrix + the read-only/1-D/no-write scope).
|
||
- Modify: `docs/plans/2026-06-16-stillpending-phase-4c-array-support.md.tasks.json` (bookkeeping at close-out).
|
||
|
||
Document: the two new `TagConfig` keys; the per-driver coverage matrix (all 5 read; live-verify status
|
||
per driver); record the 3 stale-done `§2` items (Modbus-Int64-node-type, Historian-Total,
|
||
Historian-poison-dead-letter — all Phase 4); the named out-of-scope deferrals (array writes,
|
||
multi-dim, array historization). Do NOT edit `stillpending.md`.
|
||
|
||
**Commit:** `git add` the doc files; `git commit -m "docs(phase4c): cross-driver array support + TagConfig keys + coverage matrix"`.
|
||
|
||
---
|
||
|
||
## Task 11: Full build + test + final integration review
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~6 min
|
||
**Parallelizable with:** none (blockedBy T1–T10)
|
||
|
||
`dotnet build ZB.MOM.WW.OtOpcUa.slnx` clean (`dangerouslyDisableSandbox:true`); run OpcUaServer +
|
||
Runtime + AdminUI.Tests + all 5 driver test suites green. Dispatch a **final integration reviewer**
|
||
over `git diff 050164b2..HEAD` checking: byte-parity (composer↔artifact `ExtractTagArray` identical);
|
||
defaults preserve scalar (no regression); the sink contract change is additive-only across all 4
|
||
implementers; no EF migration / no wire-proto change / no bUnit; each driver's array read is gated on
|
||
`isArray`/`arrayLength` and the scalar path is untouched. Apply any Critical/Important findings via a
|
||
fresh implementer.
|
||
|
||
---
|
||
|
||
## Task 12: Live `/run` acceptance
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~6 min
|
||
**Parallelizable with:** none (blockedBy T11)
|
||
|
||
Rebuild docker-dev central-1 from the branch (`docker compose -f docker-dev/docker-compose.yml build central-1`
|
||
then `up -d --force-recreate central-1 traefik`, `dangerouslyDisableSandbox:true`). Drive AdminUI
|
||
`http://localhost:9200` (login disabled) via the Chrome tools: author a **Modbus array** equipment tag
|
||
(`isArray` + `arrayLength`) on the seeded `MAIN-modbus-eq` → deploy → browse via Client.CLI and
|
||
confirm the node is an array (`ValueRank`/`ArrayDimensions`) + an array value flows from the Modbus
|
||
sim (`10.100.0.35:5020`). S7 / AbCip / TwinCAT / AbLegacy are **unit-proven** (fixtures down — honest,
|
||
operator-gated live). Record results; then finish the branch (merge to master + push).
|