32 KiB
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 stagesql_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 testgreen 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
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.TryGetValuesites::151deadband/ShouldPublish,:300ReadAsync,:714coalesced read,:934WriteAsync; instantiate the resolver near_tagsByNamedecl:35;Clear()inReinitializeAsync: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 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<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
_tagsByNamesite 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(EquipmentTagPlanrecord:76-83; the equipment-tag.Selectthat 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(EnsureVariablesignature) - 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— passplan.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: addbool Writable(last positional field). Update the existing materialisation consumers (the EquipmentNodeIds router build in DriverHostActor readsDriverInstanceId/FullName/EquipmentId/FolderPath/Name— adding a field is additive).Phase7Composer: in the equipment-tag.Select, setWritable: t.AccessLevel == TagAccessLevel.ReadWrite(Tag.cs:52,Enums/TagAccessLevel.cs).DeploymentArtifact.BuildEquipmentTagPlans: read the Tag'sAccessLevelfrom the artifact Tag JSON the same way (the field is already snapshotted byConfigComposer), setWritableidentically. Byte-parity required.IOpcUaAddressSpaceSink.EnsureVariable(string,string?,string,string)→ addbool writable(default false to keep other callers compiling, or update all callers).SdkAddressSpaceSink+DeferredAddressSpaceSinkforward it.OtOpcUaNodeManager.EnsureVariable: whenwritable→AccessLevel = UserAccessLevel = AccessLevels.CurrentReadWriteelseCurrentRead(do NOT attach OnWriteValue here — Task 11 owns that).Phase7Applier.MaterialiseEquipmentTags: passplan.WritabletoEnsureVariable.
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_nodeIdByDriverRefbuild inPushDesiredSubscriptions:611-633; the_childrendict:87; add message handler) - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs(reuseWriteAttribute/WriteAttributeResult:41-42— no change expected; confirmWriteAttributeisForward-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<string,(string DriverInstanceId,string FullName)> _driverRefByNodeId = new(StringComparer.Ordinal);Build it inPushDesiredSubscriptionsalongside 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 /RedundancyStateActorsnapshot for the alarm-emit gate; reuse the same_isPrimary/role field — find it inDriverHostActor). 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'sWriteAttributeResultreturns to it). The gateway (Task 11) mapsWriteAttributeResult→ outcome, so it mustAsk<…>and accept eitherNodeWriteResult(gate/resolve failures) orWriteAttributeResult(driver result) — to keep one reply type, instead have the handler doentry.Actor.Ask<DriverInstanceActor.WriteAttributeResult>(…)with a bounded timeout and.PipeTo(Sender, …)translating toNodeWriteResult. Pick the PipeTo-translate approach so the asker always receivesNodeWriteResult. Unknown node/missing child →NodeWriteResult(false,…).(Implementer: confirm how
DriverHostActorcurrently learns it is primary — grepPrimary/RoleLeader/_isPrimaryinDriverHostActor.cs; reuse that exact signal. If the actor does not already track primary, subscribe to the same redundancy snapshot the alarm-emit gate uses — seeproject_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(mirrorDeferredAddressSpaceSink) - Create: a production impl bridging to Akka (e.g.
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/ActorNodeWriteGateway.cs) thatAsksDriverHostActor.RouteNodeWrite(bounded ~10 s) →NodeWriteOutcome - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs(attachOnWriteValueinEnsureVariablewhen writable; add the handler mirroringHandleAlarmCommand:527-552; hold anIOpcUaNodeWriteGateway) - Modify: DI registration (where
IOpcUaAddressSpaceSink/DeferredAddressSpaceSinkare registered + where the deferred sink is set at hostStartAsync—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<NodeWriteOutcome> WriteAsync(string nodeId, object? value, CancellationToken ct); };NodeWriteOutcome(bool Success, string? Reason).DeferredNodeWriteGatewayqueues/forwards to a real gateway set at startup (mirrorDeferredAddressSpaceSink).ActorNodeWriteGatewayholds theDriverHostActorref and doesAsk<NodeWriteResult>(new RouteNodeWrite(nodeId,value), 10s)→NodeWriteOutcome.OtOpcUaNodeManager: take anIOpcUaNodeWriteGateway(ctor/DI). InEnsureVariable, whenwritable, setvariable.OnWriteValue = OnEquipmentTagWrite;. Handler (mirrorHandleAlarmCommand):(Blockingprivate 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); }.GetAwaiter().GetResult()is acceptable: the SDKOnWriteValuedelegate is synchronous; writes are infrequent and bounded ~10 s. Fail closed on null identity. ReturningGoodlets the SDK apply the value optimistically; the next poll republishes the confirmed register value.)- DI: register
DeferredNodeWriteGatewayas theIOpcUaNodeWriteGatewaysingleton; set its innerActorNodeWriteGatewayat hostStartAsynconce theDriverHostActorref + node manager exist (same placeSdkAddressSpaceSinkis 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.