diff --git a/docs/plans/2026-06-13-protocol-equipment-tag-linkage-plan.md b/docs/plans/2026-06-13-protocol-equipment-tag-linkage-plan.md new file mode 100644 index 00000000..c32716d3 --- /dev/null +++ b/docs/plans/2026-06-13-protocol-equipment-tag-linkage-plan.md @@ -0,0 +1,482 @@ +# Protocol-driver equipment-tag linkage + inbound write pipeline — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task. + +**Goal:** An equipment `Tag` bound to any protocol driver (Modbus, S7, AbCip, AbLegacy, TwinCAT, Focas) subscribes + publishes a live value (delivered by the shipped `FullName→NodeId` router `c4435e4f`), and an authorized operator can write that node back to the device. + +**Architecture:** **Part A** — each protocol driver gains a shared `EquipmentTagRefResolver` that, on a `_tagsByName` miss, parses the incoming ref (the equipment `TagConfig` JSON the router already keys on) into a transient driver tag-definition (Approach B; mirrors the OpcUaClient direct-ref precedent). **Part B** — a server-side inbound write pipeline: writable equipment-tag nodes + an `OnWriteValue` authz gate + a write gateway that routes through `DriverHostActor` (NodeId→driver reverse map, primary-gated) to the driver's `WriteAsync`. + +**Tech Stack:** .NET 10, System.Text.Json, Akka.NET, OPC UA Foundation stack, xUnit + Shouldly + Akka.TestKit. Design: `docs/plans/2026-06-13-protocol-equipment-tag-linkage-design.md` (master `e58f3358`). + +**Branch:** `feat/protocol-equipment-tag-linkage` off master `e58f3358`. + +--- + +## Hard rules (every task) +- Stage by path; never `git add .`. Never stage `sql_login.txt`, `src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/`, `pending.md`, `current.md`. +- Never echo secrets. No force-push, no `--no-verify`. **No Configuration entity / EF migration change.** +- No bUnit. Razor/JS/live behaviour proven only by docker-dev `/run`. +- Done = full build clean + `dotnet test` green for touched suites + both live gates (Task 8 read, Task 12 write) pass. + +## Task order & dependencies +T0 branch → **Part A:** T1 resolver → T2–T7 (six drivers, parallel) → T8 live read-gate → **Part B:** T9 (writable nodes) ∥ T10 (reverse map+route) → T11 (gateway+authz) → T12 live write-gate → finish. + +--- + +### Task 0: Create feature branch + +**Classification:** trivial +**Estimated implement time:** ~1 min +**Parallelizable with:** none + +**Files:** git only + +```bash +git checkout master && git rev-parse --short HEAD # expect e58f3358 +git checkout -b feat/protocol-equipment-tag-linkage +``` +Confirm clean tree (ignore untracked `pending.md` / `current.md` / `sql_login.txt` / `pki/`). + +--- + +### Task 1: Shared `EquipmentTagRefResolver` + unit tests + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none +**blockedBy:** Task 0 + +**Files:** +- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/EquipmentTagRefResolver.cs` +- Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/EquipmentTagRefResolverTests.cs` + +**Context:** Protocol drivers key their tag-table on the authored `DriverConfig.Tags[].Name` (`_tagsByName`). An equipment tag arrives as a ref that is the raw `TagConfig` JSON blob (no `FullName`). This resolver wraps the by-name lookup with a parse-on-miss + cache so the driver resolves both legacy and equipment refs through one call. Generic so every driver reuses it with its own def type + parser. + +**Step 1: Write the failing test** (`EquipmentTagRefResolverTests.cs`) +```csharp +using Shouldly; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests; + +public class EquipmentTagRefResolverTests +{ + private sealed record Def(string Id); + + private static EquipmentTagRefResolver Make( + Dictionary byName, Func parse) + => new(r => byName.TryGetValue(r, out var d) ? d : null, parse); + + [Fact] + public void Legacy_name_resolves_via_byName() + { + var r = Make(new() { ["Temp"] = new Def("Temp") }, _ => null); + r.TryResolve("Temp", out var def).ShouldBeTrue(); + def!.Id.ShouldBe("Temp"); + } + + [Fact] + public void Equipment_ref_resolves_via_parse_and_is_cached() + { + var calls = 0; + var r = Make(new(), ref => { calls++; return ref.StartsWith("{") ? new Def(ref) : null; }); + r.TryResolve("{\"a\":1}", out var d1).ShouldBeTrue(); + r.TryResolve("{\"a\":1}", out var d2).ShouldBeTrue(); + d1!.Id.ShouldBe("{\"a\":1}"); + calls.ShouldBe(1); // second call served from cache + } + + [Fact] + public void Garbage_ref_returns_false_and_caches_negative() + { + var calls = 0; + var r = Make(new(), _ => { calls++; return null; }); + r.TryResolve("nope", out _).ShouldBeFalse(); + r.TryResolve("nope", out _).ShouldBeFalse(); + calls.ShouldBe(1); + } + + [Fact] + public void Clear_drops_transient_cache() + { + var calls = 0; + var r = Make(new(), ref => { calls++; return new Def(ref); }); + r.TryResolve("{\"a\":1}", out _).ShouldBeTrue(); + r.Clear(); + r.TryResolve("{\"a\":1}", out _).ShouldBeTrue(); + calls.ShouldBe(2); + } +} +``` + +**Step 2: Run — expect FAIL** (type not defined): +`dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests --filter "FullyQualifiedName~EquipmentTagRefResolver"` + +**Step 3: Implement** (`EquipmentTagRefResolver.cs`) +```csharp +using System.Collections.Concurrent; + +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// Resolves a driver subscription/read/write fullReference to a driver tag-definition, +/// bridging the two authoring models: a legacy authored tag-table entry (looked up by name) +/// OR an equipment tag whose reference is its raw TagConfig JSON (parsed on first use +/// and cached). Negative results are cached too, so a genuinely-unknown reference is parsed once. +/// +/// The driver's internal tag-definition type. +public sealed class EquipmentTagRefResolver where TDef : class +{ + private readonly Func _byName; + private readonly Func _parseRef; + private readonly ConcurrentDictionary _cache = new(StringComparer.Ordinal); + + /// Authored tag-table lookup (returns null on miss). + /// Parses an equipment-tag reference (TagConfig JSON) into a transient def, or null. + public EquipmentTagRefResolver(Func byName, Func parseRef) + { + ArgumentNullException.ThrowIfNull(byName); + ArgumentNullException.ThrowIfNull(parseRef); + _byName = byName; + _parseRef = parseRef; + } + + /// True when resolves to a def (authored or equipment). + /// The wire reference handed to the driver. + /// The resolved tag-definition when this returns true. + /// when a definition was found. + public bool TryResolve(string fullReference, out TDef def) + { + var authored = _byName(fullReference); + if (authored is not null) { def = authored; return true; } + + var resolved = _cache.GetOrAdd(fullReference, _parseRef); + if (resolved is not null) { def = resolved; return true; } + + def = null!; + return false; + } + + /// Drops the transient-parse cache (call on driver reinitialise so a config change re-parses). + public void Clear() => _cache.Clear(); +} +``` + +**Step 4: Run — expect PASS.** **Step 5: Build** `dotnet build src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions`. + +**Step 6: Commit** (by path): +```bash +git add src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/EquipmentTagRefResolver.cs tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/EquipmentTagRefResolverTests.cs +git commit -m "feat(drivers): shared EquipmentTagRefResolver (by-name + parse-on-miss + cache)" +``` + +--- + +### Task 2: Modbus — equipment-tag parser + wire resolver (read + write) — **the exemplar** + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Tasks 3, 4, 5, 6, 7 +**blockedBy:** Task 1 + +**Files:** +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusEquipmentTagParser.cs` +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs` (the four `_tagsByName.TryGetValue` sites: `:151` deadband/ShouldPublish, `:300` ReadAsync, `:714` coalesced read, `:934` WriteAsync; instantiate the resolver near `_tagsByName` decl `:35`; `Clear()` in `ReinitializeAsync` `:218`) +- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusEquipmentTagTests.cs` + +**Context — the shape to parse:** the equipment `TagConfig` JSON is exactly what `ModbusTagConfigModel` reads (`src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/ModbusTagConfigModel.cs`): camelCase keys `region,address,dataType,byteOrder,bitIndex,stringLength` with enum-name string values (`"HoldingRegisters"`, `"Int16"`, …). Map to `ModbusTagDefinition` (`src/Drivers/.../Modbus.Contracts/ModbusDriverOptions.cs:283`: `Name, Region, Address(ushort), DataType, Writable=true, ByteOrder, BitIndex(byte), StringLength(ushort), …`). **The transient def's `Name` MUST equal the ref string** so the value the driver publishes back keys the router's forward map. Default `Writable=true` (node-level authz governs writes). Return false if the JSON isn't an object or lacks `region`+`address`. + +**Step 1: Write the failing test** (`ModbusEquipmentTagTests.cs`) +```csharp +using Shouldly; +using ZB.MOM.WW.OtOpcUa.Driver.Modbus; // ModbusTagDefinition, ModbusRegion, ModbusDataType +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests; + +public class ModbusEquipmentTagTests +{ + [Fact] + public void Parses_equipment_tagconfig_into_a_transient_definition() + { + var json = """{"region":"HoldingRegisters","address":40001,"dataType":"UInt16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0}"""; + ModbusEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); + def!.Name.ShouldBe(json); // identity == the ref (router publish-back key) + def.Region.ShouldBe(ModbusRegion.HoldingRegisters); + def.Address.ShouldBe((ushort)40001); + def.DataType.ShouldBe(ModbusDataType.UInt16); + } + + [Fact] + public void Rejects_a_non_address_blob() + => ModbusEquipmentTagParser.TryParse("""{"FullName":"x"}""", out _).ShouldBeFalse(); + + [Fact] + public void Rejects_garbage() + => ModbusEquipmentTagParser.TryParse("not json", out _).ShouldBeFalse(); +} +``` +(Also add a driver-level test: build a `ModbusDriver` with an empty authored `Tags`, then `ReadAsync([equipmentRefJson])` against a fake transport returns a value — **not** `BadNodeIdUnknown`. Mirror existing `ModbusDriver` read tests for the fake-transport setup.) + +**Step 2: Run — expect FAIL.** +`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusEquipmentTag"` + +**Step 3: Implement the parser** (`ModbusEquipmentTagParser.cs`) +```csharp +using System.Text.Json; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus; + +/// Parses an equipment tag's TagConfig JSON (the shape authored by the AdminUI +/// ModbusTagConfigModel) into a transient whose +/// equals the reference string itself. +public static class ModbusEquipmentTagParser +{ + private static readonly JsonSerializerOptions Opts = new() { PropertyNameCaseInsensitive = true }; + + /// Attempts to parse an equipment-tag reference into a transient definition. + /// The equipment tag's TagConfig JSON (also used as the def identity). + /// The transient definition when parsing succeeds. + /// when is a Modbus address object. + public static bool TryParse(string reference, out ModbusTagDefinition def) + { + def = null!; + if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return false; + try + { + using var doc = JsonDocument.Parse(reference); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty("region", out _) || !root.TryGetProperty("address", out var addr)) + return false; + var region = ReadEnum(root, "region", ModbusRegion.HoldingRegisters); + var dataType = ReadEnum(root, "dataType", ModbusDataType.Int16); + var byteOrder = ReadEnum(root, "byteOrder", ModbusByteOrder.BigEndian); + var bitIndex = (byte)ReadInt(root, "bitIndex"); + var stringLength = (ushort)ReadInt(root, "stringLength"); + def = new ModbusTagDefinition( + Name: reference, Region: region, Address: (ushort)addr.GetInt32(), DataType: dataType, + Writable: true, ByteOrder: byteOrder, BitIndex: bitIndex, StringLength: stringLength); + return true; + } + catch (JsonException) { return false; } + catch (FormatException) { return false; } + } + + private static TEnum ReadEnum(JsonElement o, string name, TEnum fallback) where TEnum : struct, Enum + => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String + && Enum.TryParse(e.GetString(), ignoreCase: true, out var v) ? v : fallback; + + private static int ReadInt(JsonElement o, string name) + => o.TryGetProperty(name, out var e) && e.TryGetInt32(out var v) ? v : 0; +} +``` +(Confirm `ModbusByteOrder` is in the same namespace; adjust enum names to the real ones in `ModbusDriverOptions.cs`.) + +**Step 4: Wire the resolver into `ModbusDriver.cs`.** Add a field near `_tagsByName` (`:35`): +```csharp +private readonly EquipmentTagRefResolver _resolver; +``` +Initialise it in the ctor (after `_tagsByName` exists) — note `_tagsByName` is populated in `InitializeAsync`, but the resolver only needs the *delegate*, so construct it once in the ctor: +```csharp +_resolver = new EquipmentTagRefResolver( + r => _tagsByName.TryGetValue(r, out var t) ? t : null, + r => ModbusEquipmentTagParser.TryParse(r, out var d) ? d : null); +``` +Replace each `if (!_tagsByName.TryGetValue(, out var tag))` / `if (_tagsByName.TryGetValue(, out var tag))` at `:151,:300,:714,:934` with `_resolver.TryResolve(, out var tag)`. In `ReinitializeAsync` (`:218`, before/after `InitializeAsync`) call `_resolver.Clear();`. Add `using ZB.MOM.WW.OtOpcUa.Core.Abstractions;`. Confirm the Modbus driver project references `Core.Abstractions` (it does — `IDriver` lives there). + +**Step 5: Run tests + build** +```bash +dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests +dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus +``` +Expect green + 0 errors. (Existing Modbus tests must stay green — the resolver is a superset of `_tagsByName`.) + +**Step 6: Commit** (by path): `git commit -m "feat(modbus): resolve equipment-tag refs (read + write) via EquipmentTagRefResolver"`. + +--- + +### Tasks 3–7: S7 / AbCip / AbLegacy / TwinCAT / Focas — same pattern as Task 2 + +**Classification:** standard (each) +**Estimated implement time:** ~5 min each +**Parallelizable with:** Task 2 and each other (disjoint files) +**blockedBy:** Task 1 + +Each task repeats Task 2's recipe for one driver. Per driver, do the three steps: (1) a `…EquipmentTagParser.TryParse` in that driver's `*.Contracts` project mirroring its AdminUI editor model's keys; (2) instantiate `EquipmentTagRefResolver` in the driver; (3) replace its `_tagsByName.TryGetValue` lookup sites with `_resolver.TryResolve` + `Clear()` on reinit; plus parser + resolve-on-miss unit tests in that driver's `*.Tests` project. + +| Task | Driver | Driver file (grep `_tagsByName` for sites) | Contracts project (parser) | Editor model to mirror | Tests project | +|---|---|---|---|---|---| +| 3 | S7 | `src/Drivers/.../Driver.S7/S7Driver.cs` | `…Driver.S7.Contracts` | `AdminUI/Uns/TagEditors/S7TagConfigModel.cs` | `…Driver.S7.Tests` | +| 4 | AbCip | `src/Drivers/.../Driver.AbCip/AbCipDriver.cs` | `…Driver.AbCip.Contracts` | `AbCipTagConfigModel.cs` | `…Driver.AbCip.Tests` | +| 5 | AbLegacy | `src/Drivers/.../Driver.AbLegacy/AbLegacyDriver.cs` | `…Driver.AbLegacy.Contracts` | `AbLegacyTagConfigModel.cs` | `…Driver.AbLegacy.Tests` | +| 6 | TwinCAT | `src/Drivers/.../Driver.TwinCAT/TwinCATDriver.cs` | `…Driver.TwinCAT.Contracts` | `TwinCATTagConfigModel.cs` | `…Driver.TwinCAT.Tests` | +| 7 | Focas | `src/Drivers/.../Driver.FOCAS/FocasDriver.cs` | `…Driver.FOCAS.Contracts` | `FocasTagConfigModel.cs` | `…Driver.FOCAS.Tests` | + +**Per-driver notes (read the editor model + the driver's tag-definition record first):** +- The transient def's identity field (the equivalent of Modbus `Name` — the key `_tagsByName` / the publish-back uses) MUST equal the ref string. +- Each driver's `_tagsByName` site count differs (S7 5, AbCip 10, AbLegacy 8, TwinCAT 9, Focas 6 — includes the decl + populate loop, which you do NOT change; only the *lookup* sites become `_resolver.TryResolve`). +- Some drivers route reads through a shared `PollGroupEngine`; the lookup still happens inside the driver's read/write methods — wire the resolver there. +- Default the transient def writable; node-level authz (Part B) governs actual writes. +- Required-field guard: return false unless the JSON object carries that driver's mandatory address fields (e.g. S7 area+db+offset; AbCip tag path) so a non-address blob still skips. + +**Commit each** (by path): `git commit -m "feat(): resolve equipment-tag refs via EquipmentTagRefResolver"`. + +--- + +### Task 8: Live read-gate — Modbus equipment tag shows a live changing value (Part A done) + +**Classification:** verification +**Estimated implement time:** ~5 min + live +**Parallelizable with:** none +**blockedBy:** Tasks 2, 3, 4, 5, 6, 7 + +**Files:** none (rig + DB only; never commit rig artifacts). + +**Step 1:** Full build + driver suites: +```bash +dotnet build ZB.MOM.WW.OtOpcUa.slnx +dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests +``` +Expect 0 errors; green. (Optionally run S7/AbCip/AbLegacy/TwinCAT/FOCAS `.Tests` too.) + +**Step 2: Author a Modbus equipment tag in the dev rig** (agent-driven; dev UI login disabled, drive the DB or AdminUI). The pymodbus sim is at `10.100.0.35:5020` (start if down: `ssh 10.100.0.35 'cd /opt/otopcua-modbus && docker compose up -d'`). On the `nw-uns` Equipment namespace + the `MAIN` Modbus driver instance, author an equipment `Tag` (EquipmentId set, DriverInstanceId = the Modbus driver, `TagConfig = {"region":"HoldingRegisters","address":,"dataType":"UInt16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0}`, `AccessLevel = Read`). If unsure which Modbus driver/registers the rig exposes, inspect `dbo.DriverInstance` (DriverType='Modbus') + the sim's served registers first. + +**Step 3: Rebuild central on the branch + redeploy** +```bash +docker compose -f docker-dev/docker-compose.yml up -d --build migrator central-1 central-2 +curl -sS -X POST http://localhost:9200/api/deployments -H "X-Api-Key: docker-dev-deploy-key" -H "Content-Type: application/json" -d '{}' +``` +If the Modbus driver is stuck (faulted/Reconnecting ignores `ApplyDelta`), restart `central-1 central-2` to respawn it fresh from the artifact (known gotcha). + +**Step 4: Confirm the live value** — read the equipment NodeId (`ns=2;s=/`) from `opc.tcp://localhost:4840` via Client.CLI twice; expect a Good, **changing** value (the sim increments), not `BadWaitingForInitialData`: +```bash +dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n "ns=2;s=/" +``` +This live-proves Part A end-to-end. **Gate: do not start Part B until this passes.** + +--- + +### Task 9: Part B — writable equipment-tag nodes (`Writable` plan-parity + AccessLevel) + +**Classification:** high-risk +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 10 +**blockedBy:** Task 8 + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs` (`EquipmentTagPlan` record `:76-83`; the equipment-tag `.Select` that builds plans `~:310-326`) +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs` (`BuildEquipmentTagPlans` `:365-447`) +- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs` (`EnsureVariable` signature) +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs` + `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs` (forward the new arg) +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` (`EnsureVariable` `:636-669` — set AccessLevel) +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs` (`MaterialiseEquipmentTags` `:162-199` — pass `plan.Writable`) +- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/` (plan-parity) + `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/` (artifact derivation) + +**Step 1 (TDD): plan-parity test** — for a Tag with `AccessLevel = ReadWrite` bound to an Equipment-kind driver, `Phase7Composer`'s `EquipmentTagPlan.Writable == true`, and `DeploymentArtifact.BuildEquipmentTagPlans` over the *same* composed artifact yields `Writable == true` (and `Read ⇒ false`). Mirror the existing Composer↔Artifact parity tests for equipment tags. + +**Step 2: Implement** +- `EquipmentTagPlan`: add `bool Writable` (last positional field). Update the existing materialisation consumers (the EquipmentNodeIds router build in DriverHostActor reads `DriverInstanceId/FullName/EquipmentId/FolderPath/Name` — adding a field is additive). +- `Phase7Composer`: in the equipment-tag `.Select`, set `Writable: t.AccessLevel == TagAccessLevel.ReadWrite` (`Tag.cs:52`, `Enums/TagAccessLevel.cs`). +- `DeploymentArtifact.BuildEquipmentTagPlans`: read the Tag's `AccessLevel` from the artifact Tag JSON the same way (the field is already snapshotted by `ConfigComposer`), set `Writable` identically. **Byte-parity required.** +- `IOpcUaAddressSpaceSink.EnsureVariable(string,string?,string,string)` → add `bool writable` (default false to keep other callers compiling, or update all callers). `SdkAddressSpaceSink` + `DeferredAddressSpaceSink` forward it. `OtOpcUaNodeManager.EnsureVariable`: when `writable` → `AccessLevel = UserAccessLevel = AccessLevels.CurrentReadWrite` else `CurrentRead` (do NOT attach OnWriteValue here — Task 11 owns that). +- `Phase7Applier.MaterialiseEquipmentTags`: pass `plan.Writable` to `EnsureVariable`. + +**Step 3: Run tests + build** the full solution (signature change ripples). Expect 0 errors; green. + +**Step 4: Commit** (by path): `git commit -m "feat(server): equipment-tag node writability from Tag.AccessLevel (parity-safe, no migration)"`. + +--- + +### Task 10: Part B — reverse NodeId→driver map + `RouteNodeWrite` (primary-gated) + +**Classification:** high-risk +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 9 +**blockedBy:** Task 8 + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` (the `_nodeIdByDriverRef` build in `PushDesiredSubscriptions` `:611-633`; the `_children` dict `:87`; add message handler) +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs` (reuse `WriteAttribute`/`WriteAttributeResult` `:41-42` — no change expected; confirm `WriteAttribute` is `Forward`-able) +- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/` (Akka.TestKit) + +**Step 1 (TDD): Akka.TestKit tests** — on a `DriverHostActor` (or a focused harness) with a child + a known equipment tag: `RouteNodeWrite(nodeId, value)` on the **primary** resolves `nodeId → (driver, fullName)` and the child receives `WriteAttribute(fullName, value)` (probe as child) → replies `WriteAttributeResult` → asker gets `NodeWriteResult(true,…)`; on a **secondary** → `NodeWriteResult(false,"not primary")` and no child message; unknown nodeId → `NodeWriteResult(false,…)`. + +**Step 2: Implement** +- Add `private readonly Dictionary _driverRefByNodeId = new(StringComparer.Ordinal);` Build it in `PushDesiredSubscriptions` alongside the forward map (clear + repopulate each apply): for each composition equipment tag, `_driverRefByNodeId[EquipmentNodeIds.Variable(eq,folder,name)] = (DriverInstanceId, FullName)`. +- Messages: `public sealed record RouteNodeWrite(string NodeId, object? Value);` `public sealed record NodeWriteResult(bool Success, string? Reason);` (place near the actor's other message records). +- Handler `Receive`: primary gate first — reuse the actor's existing redundancy/role signal (it already consumes the driver role-leader / `RedundancyStateActor` snapshot for the alarm-emit gate; reuse the same `_isPrimary`/role field — find it in `DriverHostActor`). If not primary → `Sender.Tell(new NodeWriteResult(false, "not primary"))`. Else resolve `_driverRefByNodeId.TryGetValue(NodeId)` → `_children.TryGetValue(driverId)` → `entry.Actor.Forward(new DriverInstanceActor.WriteAttribute(fullName, Value!))` (Forward preserves the asker as Sender so the child's `WriteAttributeResult` returns to it). The gateway (Task 11) maps `WriteAttributeResult` → outcome, so it must `Ask<…>` and accept either `NodeWriteResult` (gate/resolve failures) or `WriteAttributeResult` (driver result) — to keep one reply type, instead have the handler do `entry.Actor.Ask(…)` with a bounded timeout and `.PipeTo(Sender, …)` translating to `NodeWriteResult`. **Pick the PipeTo-translate approach** so the asker always receives `NodeWriteResult`. Unknown node/missing child → `NodeWriteResult(false,…)`. + + (Implementer: confirm how `DriverHostActor` currently learns it is primary — grep `Primary`/`RoleLeader`/`_isPrimary` in `DriverHostActor.cs`; reuse that exact signal. If the actor does not already track primary, subscribe to the same redundancy snapshot the alarm-emit gate uses — see `project_redundancy_state_delivery` / the alerts-emit Primary gate — do NOT invent a second mechanism.) + +**Step 3: Run Runtime.Tests + build.** Expect green. + +**Step 4: Commit** (by path): `git commit -m "feat(runtime): NodeId→driver reverse routing + primary-gated RouteNodeWrite"`. + +--- + +### Task 11: Part B — write gateway + `OnWriteValue` authz handler (the inbound bridge) + +**Classification:** high-risk +**Estimated implement time:** ~5 min +**Parallelizable with:** none +**blockedBy:** Tasks 9, 10 + +**Files:** +- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaNodeWriteGateway.cs` (+ `NodeWriteOutcome`) +- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredNodeWriteGateway.cs` (mirror `DeferredAddressSpaceSink`) +- Create: a production impl bridging to Akka (e.g. `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/ActorNodeWriteGateway.cs`) that `Ask`s `DriverHostActor.RouteNodeWrite` (bounded ~10 s) → `NodeWriteOutcome` +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` (attach `OnWriteValue` in `EnsureVariable` when writable; add the handler mirroring `HandleAlarmCommand` `:527-552`; hold an `IOpcUaNodeWriteGateway`) +- Modify: DI registration (where `IOpcUaAddressSpaceSink`/`DeferredAddressSpaceSink` are registered + where the deferred sink is set at host `StartAsync` — `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs` + `OtOpcUaServerHostedService`) +- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/` (authz gate + outcome mapping) + +**Step 1 (TDD): authz-gate unit tests** — a small testable handler `EvaluateWrite(RoleCarryingUserIdentity? identity, …)`: identity null → `BadUserAccessDenied`; roles lacking `WriteOperate` → `BadUserAccessDenied`; roles containing `WriteOperate` → calls the gateway (inject a fake returning success) → `Good`; gateway failure → mapped `Bad*`. Keep the role-extraction/gate logic in a method you can unit-test without a live SDK session (pass the `RoleCarryingUserIdentity` + a fake gateway). + +**Step 2: Implement** +- `IOpcUaNodeWriteGateway { Task WriteAsync(string nodeId, object? value, CancellationToken ct); }`; `NodeWriteOutcome(bool Success, string? Reason)`. `DeferredNodeWriteGateway` queues/forwards to a real gateway set at startup (mirror `DeferredAddressSpaceSink`). `ActorNodeWriteGateway` holds the `DriverHostActor` ref and does `Ask(new RouteNodeWrite(nodeId,value), 10s)` → `NodeWriteOutcome`. +- `OtOpcUaNodeManager`: take an `IOpcUaNodeWriteGateway` (ctor/DI). In `EnsureVariable`, when `writable`, set `variable.OnWriteValue = OnEquipmentTagWrite;`. Handler (mirror `HandleAlarmCommand`): + ```csharp + private ServiceResult OnEquipmentTagWrite(ISystemContext context, NodeState node, NumericRange indexRange, + QualifiedName dataEncoding, ref object value, ref StatusCode statusCode, ref DateTime timestamp) + { + var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity; + if (identity is null || !identity.Roles.Contains(OpcUaDataPlaneRoles.WriteOperate, StringComparer.OrdinalIgnoreCase)) + return StatusCodes.BadUserAccessDenied; + var nodeId = node.NodeId.Identifier?.ToString() ?? string.Empty; + var outcome = _writeGateway.WriteAsync(nodeId, value, CancellationToken.None).GetAwaiter().GetResult(); + return outcome.Success ? ServiceResult.Good : new ServiceResult(StatusCodes.BadNotWritable, outcome.Reason); + } + ``` + (Blocking `.GetAwaiter().GetResult()` is acceptable: the SDK `OnWriteValue` delegate is synchronous; writes are infrequent and bounded ~10 s. Fail closed on null identity. Returning `Good` lets the SDK apply the value optimistically; the next poll republishes the confirmed register value.) +- DI: register `DeferredNodeWriteGateway` as the `IOpcUaNodeWriteGateway` singleton; set its inner `ActorNodeWriteGateway` at host `StartAsync` once the `DriverHostActor` ref + node manager exist (same place `SdkAddressSpaceSink` is set on the deferred sink). + +**Step 3: Run OpcUaServer.Tests + full build.** Expect green; 0 errors. + +**Step 4: Commit** (by path): `git commit -m "feat(server): inbound operator-write pipeline — OnWriteValue authz gate + node-write gateway"`. + +--- + +### Task 12: Live write-gate + finish + +**Classification:** verification +**Estimated implement time:** ~10 min + live +**Parallelizable with:** none +**blockedBy:** Task 11 + +**Files:** none. + +**Step 1:** `dotnet build ZB.MOM.WW.OtOpcUa.slnx` + `dotnet test` the touched server suites (OpcUaServer.Tests, Runtime.Tests) + driver suites. Expect green. + +**Step 2: Rebuild central on the branch + redeploy** (as Task 8). Make the Task-8 Modbus equipment tag `AccessLevel = ReadWrite` (so its node is writable). Redeploy; confirm the node now advertises write access. + +**Step 3: Authorized write** — connect Client.CLI as an LDAP user holding `WriteOperate` against the shared GLAuth (`10.100.0.35:3893`, baseDN `dc=zb,dc=local`, user password `password`; OPC UA session auth is separate from the disabled dev UI login — see `docs/Client.CLI.md` for the username/password connect flags + map the user's LDAP group to `WriteOperate` per `Security:Ldap`). Write a value to `ns=2;s=/`, then read the corresponding holding register back from the pymodbus sim (or re-read the node after the next poll) to confirm the register changed. + +**Step 4: Unauthorized write is denied** — connect as an anonymous/ReadOnly session and write the same node → expect `BadUserAccessDenied`. + +**Step 5:** On both live gates green (Task 8 read + this write), finish via `superpowers-extended-cc:finishing-a-development-branch` (intent: merge-to-master + push). Update `pending.md` (gap b closed; note write-through shipped) and the `project_galaxy_standard_driver` memory — do NOT stage `pending.md`. + +--- + +## Out of scope +Tune/Configure write-role granularity (needs equipment-tag SecurityClassification — future schema); unifying driver parsers with the AdminUI editor models; secondary-node writes; Phase B native alarms / Phase C server historian. diff --git a/docs/plans/2026-06-13-protocol-equipment-tag-linkage-plan.md.tasks.json b/docs/plans/2026-06-13-protocol-equipment-tag-linkage-plan.md.tasks.json new file mode 100644 index 00000000..3e1b4ad3 --- /dev/null +++ b/docs/plans/2026-06-13-protocol-equipment-tag-linkage-plan.md.tasks.json @@ -0,0 +1,19 @@ +{ + "planPath": "docs/plans/2026-06-13-protocol-equipment-tag-linkage-plan.md", + "tasks": [ + {"id": 341, "subject": "Task 0: Create feature branch", "status": "pending"}, + {"id": 342, "subject": "Task 1: Shared EquipmentTagRefResolver + tests", "status": "pending", "blockedBy": [341]}, + {"id": 343, "subject": "Task 2: Modbus equipment-tag resolver (exemplar)", "status": "pending", "blockedBy": [342]}, + {"id": 344, "subject": "Task 3: S7 equipment-tag resolver", "status": "pending", "blockedBy": [342]}, + {"id": 345, "subject": "Task 4: AbCip equipment-tag resolver", "status": "pending", "blockedBy": [342]}, + {"id": 346, "subject": "Task 5: AbLegacy equipment-tag resolver", "status": "pending", "blockedBy": [342]}, + {"id": 347, "subject": "Task 6: TwinCAT equipment-tag resolver", "status": "pending", "blockedBy": [342]}, + {"id": 348, "subject": "Task 7: Focas equipment-tag resolver", "status": "pending", "blockedBy": [342]}, + {"id": 349, "subject": "Task 8: Live read-gate (Part A done)", "status": "pending", "blockedBy": [343, 344, 345, 346, 347, 348]}, + {"id": 350, "subject": "Task 9: Writable equipment-tag nodes (Part B)", "status": "pending", "blockedBy": [349]}, + {"id": 351, "subject": "Task 10: Reverse map + RouteNodeWrite (Part B)", "status": "pending", "blockedBy": [349]}, + {"id": 352, "subject": "Task 11: Write gateway + OnWriteValue authz (Part B)", "status": "pending", "blockedBy": [350, 351]}, + {"id": 353, "subject": "Task 12: Live write-gate + finish", "status": "pending", "blockedBy": [352]} + ], + "lastUpdated": "2026-06-13" +}