docs(drivers): plan — protocol-driver equipment-tag linkage + write pipeline (13 tasks)
This commit is contained in:
@@ -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<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 → 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<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`)
|
||||
```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<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`)
|
||||
```csharp
|
||||
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):
|
||||
```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;
|
||||
|
||||
/// <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`):
|
||||
```csharp
|
||||
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:
|
||||
```csharp
|
||||
_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**
|
||||
```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<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:
|
||||
```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":<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**
|
||||
```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=<EquipmentId>/<TagName>`) 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=<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 `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<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 `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<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`):
|
||||
```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=<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.
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user