Files
lmxopcua/docs/plans/2026-06-13-protocol-equipment-tag-linkage-plan.md
T

32 KiB
Raw Blame History

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<TDef> 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 → T2T7 (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

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<TDef> + 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)

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<Def> Make(
        Dictionary<string, Def> byName, Func<string, Def?> 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)

using System.Collections.Concurrent;

namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;

/// <summary>
///     Resolves a driver subscription/read/write <c>fullReference</c> 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 <c>TagConfig</c> JSON (parsed on first use
///     and cached). Negative results are cached too, so a genuinely-unknown reference is parsed once.
/// </summary>
/// <typeparam name="TDef">The driver's internal tag-definition type.</typeparam>
public sealed class EquipmentTagRefResolver<TDef> where TDef : class
{
    private readonly Func<string, TDef?> _byName;
    private readonly Func<string, TDef?> _parseRef;
    private readonly ConcurrentDictionary<string, TDef?> _cache = new(StringComparer.Ordinal);

    /// <param name="byName">Authored tag-table lookup (returns null on miss).</param>
    /// <param name="parseRef">Parses an equipment-tag reference (TagConfig JSON) into a transient def, or null.</param>
    public EquipmentTagRefResolver(Func<string, TDef?> byName, Func<string, TDef?> parseRef)
    {
        ArgumentNullException.ThrowIfNull(byName);
        ArgumentNullException.ThrowIfNull(parseRef);
        _byName = byName;
        _parseRef = parseRef;
    }

    /// <summary>True when <paramref name="fullReference"/> resolves to a def (authored or equipment).</summary>
    /// <param name="fullReference">The wire reference handed to the driver.</param>
    /// <param name="def">The resolved tag-definition when this returns true.</param>
    /// <returns><see langword="true"/> when a definition was found.</returns>
    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;
    }

    /// <summary>Drops the transient-parse cache (call on driver reinitialise so a config change re-parses).</summary>
    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):

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)

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)

using System.Text.Json;

namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;

/// <summary>Parses an equipment tag's <c>TagConfig</c> JSON (the shape authored by the AdminUI
/// <c>ModbusTagConfigModel</c>) into a transient <see cref="ModbusTagDefinition"/> whose
/// <see cref="ModbusTagDefinition.Name"/> equals the reference string itself.</summary>
public static class ModbusEquipmentTagParser
{
    private static readonly JsonSerializerOptions Opts = new() { PropertyNameCaseInsensitive = true };

    /// <summary>Attempts to parse an equipment-tag reference into a transient definition.</summary>
    /// <param name="reference">The equipment tag's TagConfig JSON (also used as the def identity).</param>
    /// <param name="def">The transient definition when parsing succeeds.</param>
    /// <returns><see langword="true"/> when <paramref name="reference"/> is a Modbus address object.</returns>
    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<TEnum>(JsonElement o, string name, TEnum fallback) where TEnum : struct, Enum
        => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
           && Enum.TryParse<TEnum>(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):

private readonly EquipmentTagRefResolver<ModbusTagDefinition> _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:

_resolver = new EquipmentTagRefResolver<ModbusTagDefinition>(
    r => _tagsByName.TryGetValue(r, out var t) ? t : null,
    r => ModbusEquipmentTagParser.TryParse(r, out var d) ? d : null);

Replace each if (!_tagsByName.TryGetValue(<ref>, out var tag)) / if (_tagsByName.TryGetValue(<ref>, out var tag)) at :151,:300,:714,:934 with _resolver.TryResolve(<ref>, 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

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 37: 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<TDef> 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(<driver>): 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:

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":<a register the sim serves>,"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

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=<EquipmentId>/<TagName>) from opc.tcp://localhost:4840 via Client.CLI twice; expect a Good, changing value (the sim increments), not BadWaitingForInitialData:

dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n "ns=2;s=<EquipmentId>/<TagName>"

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 writableAccessLevel = 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 secondaryNodeWriteResult(false,"not primary") and no child message; unknown nodeId → NodeWriteResult(false,…).

Step 2: Implement

  • Add private readonly Dictionary<string,(string DriverInstanceId,string FullName)> _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<RouteNodeWrite>: 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<DriverInstanceActor.WriteAttributeResult>(…) 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 Asks 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 StartAsyncsrc/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 WriteOperateBadUserAccessDenied; 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<NodeWriteOutcome> 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<NodeWriteResult>(new RouteNodeWrite(nodeId,value), 10s)NodeWriteOutcome.
  • OtOpcUaNodeManager: take an IOpcUaNodeWriteGateway (ctor/DI). In EnsureVariable, when writable, set variable.OnWriteValue = OnEquipmentTagWrite;. Handler (mirror HandleAlarmCommand):
    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=<EquipmentId>/<TagName>, 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.