760 lines
44 KiB
Markdown
760 lines
44 KiB
Markdown
# FixedTree → Equipment dynamic-injection Implementation Plan
|
||
|
||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||
|
||
**Goal:** After an `ITagDiscovery` driver connects, dynamically graft its discovered FixedTree nodes into the served Equipment/UNS OPC UA address space under a driver-named subfolder (`EQ-…/FOCAS/…`), carrying live values — reusing the existing materialize → subscribe → poll → push pipeline.
|
||
|
||
**Architecture:** Treat discovered nodes as "synthetic equipment tags" injected at runtime. A capturing `IAddressSpaceBuilder` records each driver's `DiscoverAsync` output (zero driver changes); `DriverInstanceActor` runs discovery post-connect (bounded retry, since FOCAS's `FixedTreeCache` populates ~0–2 s after connect) and ships a `DiscoveredNodesReady` message; `DriverHostActor` maps the nodes under the equipment, extends `_nodeIdByDriverRef` + the desired-subscription set, and tells `OpcUaPublishActor` to incrementally materialize them (idempotent `EnsureFolder`/`EnsureVariable`, no full teardown), emitting a `GeneralModelChangeEvent`. Survives redeploys (re-applied after `PushDesiredSubscriptions`) and restarts (re-discovered on reconnect).
|
||
|
||
**Tech Stack:** .NET 10, Akka.NET (Akka.Hosting, Akka.TestKit.Xunit2), OPC UA (`OPCFoundation.NetStandard.Opc.Ua`), xUnit v2 + Shouldly.
|
||
|
||
**Design doc:** [`2026-06-26-otopcua-fixedtree-equipment-injection-design.md`](2026-06-26-otopcua-fixedtree-equipment-injection-design.md). Base branch: `fix/focas-poll-io-serialization` (this builds on the deployed driver-host bootstrap re-spawn + FOCAS I/O fixes; not yet merged to `master`).
|
||
|
||
**Key code anchors (verified 2026-06-26):**
|
||
- `IAddressSpaceBuilder` / `IVariableHandle` / `DriverAttributeInfo` — `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/`.
|
||
- Reference capturing builder (flat collector): `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/BrowseCommand.cs:120` (`CollectingAddressSpaceBuilder`).
|
||
- NodeId scheme: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/EquipmentNodeIds.cs` (`Variable(equipmentId, folderPath, name)` → `{parent}/{name}`; `SubFolder` → `{equipmentId}/{folderPath}`).
|
||
- Materialize pattern: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs:248` (`MaterialiseEquipmentTags`) + `SafeEnsureFolder`/`SafeEnsureVariable`.
|
||
- Node manager: `OtOpcUaNodeManager.EnsureFolder` (`:1282`), `EnsureVariable` (`:1367`, seeds `BadWaitingForInitialData`), `BuildNodeShapeChangedEvent` (`:1525`, verb `DataTypeChanged` — model for a `NodeAdded` sibling).
|
||
- Publish actor receive + materialize calls: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs:217` (Receive block), `HandleRebuild` (`:275`).
|
||
- Driver value route: `DriverHostActor.ForwardToMux` (`:525`), `_nodeIdByDriverRef` built in `PushDesiredSubscriptions` (`:1019`, sends `SetDesiredSubscriptions` `:1052`), `ChildEntry` (`:203`), Receive blocks (`:482`, `:512`).
|
||
- Driver connect hook: `DriverInstanceActor` `_driver` field (`:110`), `Connected()` (`:317`), transition at `InitializeSucceeded` (`:278`); `SetDesiredSubscriptions` live re-subscribe path (`:340-353`).
|
||
- FOCAS discovery (reused verbatim): `FocasDriver.DiscoverAsync` (`:408`) emits `FOCAS/{deviceHost}/<section>/…`; FixedTree leaf `FullName` = `{deviceHost}/{path}`; suppresses FixedTree until `FixedTreeCache` set.
|
||
|
||
---
|
||
|
||
## Conventions for every task
|
||
|
||
- **TDD:** write the failing test first, run it (confirm the expected failure), implement minimally, run again (green), commit.
|
||
- **Build:** `dotnet build ZB.MOM.WW.OtOpcUa.slnx` from the repo root.
|
||
- **Run a single test class:** `dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~<ClassName>"`.
|
||
- **Commits:** Conventional Commits, on `fix/focas-poll-io-serialization` (do NOT touch the pre-existing unrelated working-tree edits: `CLAUDE.md`, `docker-dev/docker-compose.yml`, `pending.md`, `stillpending.md`, `docs/plans/2026-06-19-followups-batch.md.tasks.json` — `git add` only this feature's files).
|
||
- **No new dependencies, no proto change, no EF migration.** All edits are within existing projects.
|
||
|
||
---
|
||
|
||
## Task 1: `DiscoveredNode` DTO + path-tracking `CapturingAddressSpaceBuilder`
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** Task 3
|
||
|
||
**Files:**
|
||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNode.cs`
|
||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/CapturingAddressSpaceBuilder.cs`
|
||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/CapturingAddressSpaceBuilderTests.cs`
|
||
|
||
Unlike the CLI's flat `CollectingAddressSpaceBuilder`, this one **tracks folder nesting** so each variable records its full path segments (e.g. `["FOCAS","10.201.31.5:8193","Identity"]` + browse `SeriesNumber`).
|
||
|
||
**Step 1: Write the failing test**
|
||
|
||
```csharp
|
||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||
using Shouldly;
|
||
using Xunit;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||
|
||
[Trait("Category", "Unit")]
|
||
public sealed class CapturingAddressSpaceBuilderTests
|
||
{
|
||
[Fact]
|
||
public void Records_nested_path_segments_full_reference_and_metadata()
|
||
{
|
||
var b = new CapturingAddressSpaceBuilder();
|
||
var focas = b.Folder("FOCAS", "FOCAS");
|
||
var device = focas.Folder("10.0.0.5:8193", "cnc");
|
||
var identity = device.Folder("Identity", "Identity");
|
||
identity.Variable("SeriesNumber", "SeriesNumber", new DriverAttributeInfo(
|
||
FullName: "10.0.0.5:8193/Identity/SeriesNumber",
|
||
DriverDataType: DriverDataType.String, IsArray: false, ArrayDim: null,
|
||
SecurityClass: SecurityClassification.ViewOnly, IsHistorized: false));
|
||
|
||
b.Nodes.Count.ShouldBe(1);
|
||
var n = b.Nodes[0];
|
||
n.FolderPathSegments.ShouldBe(new[] { "FOCAS", "10.0.0.5:8193", "Identity" });
|
||
n.BrowseName.ShouldBe("SeriesNumber");
|
||
n.FullReference.ShouldBe("10.0.0.5:8193/Identity/SeriesNumber");
|
||
n.DataType.ShouldBe(DriverDataType.String);
|
||
n.Writable.ShouldBeFalse(); // ViewOnly → read-only
|
||
}
|
||
|
||
[Fact]
|
||
public void AddProperty_is_ignored_and_alarm_marking_is_a_noop_sink()
|
||
{
|
||
var b = new CapturingAddressSpaceBuilder();
|
||
var f = b.Folder("FOCAS", "FOCAS");
|
||
f.AddProperty("Manufacturer", DriverDataType.String, "FANUC"); // ignored, no throw
|
||
var h = f.Variable("V", "V", new DriverAttributeInfo("ref", DriverDataType.Int32, false, null,
|
||
SecurityClassification.ViewOnly, false, IsAlarm: true));
|
||
var sink = h.MarkAsAlarmCondition(new AlarmConditionInfo("src", AlarmSeverity.Low, null));
|
||
sink.ShouldNotBeNull(); // no-op sink, alarms out of scope
|
||
b.Nodes.Count.ShouldBe(1);
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: Run to verify it fails** — `dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~CapturingAddressSpaceBuilderTests"` → FAIL (types don't exist).
|
||
|
||
**Step 3: Implement `DiscoveredNode.cs`**
|
||
|
||
```csharp
|
||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||
|
||
/// <summary>
|
||
/// A flattened variable captured from a driver's <see cref="ITagDiscovery.DiscoverAsync"/> stream
|
||
/// by <see cref="CapturingAddressSpaceBuilder"/>. Folder nesting is preserved in
|
||
/// <see cref="FolderPathSegments"/> so the injector can re-root the node under an equipment.
|
||
/// </summary>
|
||
public sealed record DiscoveredNode(
|
||
IReadOnlyList<string> FolderPathSegments,
|
||
string BrowseName,
|
||
string DisplayName,
|
||
string FullReference,
|
||
DriverDataType DataType,
|
||
bool IsArray,
|
||
uint? ArrayDim,
|
||
bool Writable,
|
||
bool IsHistorized);
|
||
```
|
||
|
||
**Step 3b: Implement `CapturingAddressSpaceBuilder.cs`**
|
||
|
||
```csharp
|
||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||
|
||
/// <summary>
|
||
/// An <see cref="IAddressSpaceBuilder"/> that RECORDS the streamed tree instead of creating OPC UA
|
||
/// nodes — used to capture an <see cref="ITagDiscovery"/> driver's discovered hierarchy so the
|
||
/// runtime can graft it under an equipment node. Folder nesting is tracked (each child builder
|
||
/// carries its accumulated path), so every variable records its full <see cref="DiscoveredNode.FolderPathSegments"/>.
|
||
/// <para>Value nodes only: <see cref="AddProperty"/> is ignored and alarm marking returns a no-op sink
|
||
/// (discovered alarms are out of scope — alarms come via the config path).</para>
|
||
/// <para>Single-threaded: a driver's <c>DiscoverAsync</c> streams on one caller; the root and its child
|
||
/// builders share one <see cref="List{T}"/>. Not thread-safe by design.</para>
|
||
/// </summary>
|
||
public sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder
|
||
{
|
||
private readonly List<DiscoveredNode> _nodes;
|
||
private readonly IReadOnlyList<string> _path;
|
||
|
||
public CapturingAddressSpaceBuilder() : this([], []) { }
|
||
|
||
private CapturingAddressSpaceBuilder(List<DiscoveredNode> nodes, IReadOnlyList<string> path)
|
||
{
|
||
_nodes = nodes;
|
||
_path = path;
|
||
}
|
||
|
||
/// <summary>All variables captured across the whole tree (shared by the root and every child scope).</summary>
|
||
public IReadOnlyList<DiscoveredNode> Nodes => _nodes;
|
||
|
||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||
=> new CapturingAddressSpaceBuilder(_nodes, [.. _path, browseName]);
|
||
|
||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||
{
|
||
_nodes.Add(new DiscoveredNode(
|
||
FolderPathSegments: _path,
|
||
BrowseName: browseName,
|
||
DisplayName: displayName,
|
||
FullReference: attributeInfo.FullName,
|
||
DataType: attributeInfo.DriverDataType,
|
||
IsArray: attributeInfo.IsArray,
|
||
ArrayDim: attributeInfo.ArrayDim,
|
||
Writable: attributeInfo.SecurityClass != SecurityClassification.ViewOnly,
|
||
IsHistorized: attributeInfo.IsHistorized));
|
||
return new NullHandle(attributeInfo.FullName);
|
||
}
|
||
|
||
public void AddProperty(string browseName, DriverDataType dataType, object? value) { /* metadata only — ignored */ }
|
||
|
||
private sealed class NullHandle(string fullRef) : IVariableHandle
|
||
{
|
||
public string FullReference => fullRef;
|
||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||
}
|
||
|
||
private sealed class NullSink : IAlarmConditionSink
|
||
{
|
||
public void OnTransition(AlarmEventArgs args) { }
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 4: Run to verify it passes** — same filter → PASS.
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNode.cs \
|
||
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/CapturingAddressSpaceBuilder.cs \
|
||
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/CapturingAddressSpaceBuilderTests.cs
|
||
git commit -m "feat(otopcua): capturing address-space builder for driver discovery"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: `DiscoveredNodeMapper` — map discovered nodes under an equipment
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Task 3
|
||
|
||
**Files:**
|
||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/DiscoveredInjection.cs` (the `DiscoveredFolder`/`DiscoveredVariable` materialize DTOs — placed in OpcUaServer so both the applier and the Runtime mapper can reference them; Runtime already references OpcUaServer)
|
||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNodeMapper.cs`
|
||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveredNodeMapperTests.cs`
|
||
|
||
**Pure function** turning `IReadOnlyList<DiscoveredNode>` + an `equipmentId` + the driver's authored-tag refs into folders + variables (NodeIds under the equipment) + routing entries. Rules:
|
||
- **Device-folder collapse:** if every node shares an identical segment at index 1 (the single device folder under the driver root), drop index 1 → `EQ/FOCAS/Identity/…` rather than `EQ/FOCAS/<deviceHost>/Identity/…`. With ≥2 devices the segments differ → not collapsed (device level retained, degrades gracefully — multi-device equipment mapping itself is a deferred follow-up).
|
||
- **Dedup:** skip any node whose `FullReference` is in `authoredRefs` (already a Config-DB equipment tag for this driver — applies to drivers like Galaxy whose discovery refs equal the equipment-tag FullNames; for FOCAS the FixedTree refs never match authored refs, so all FixedTree nodes pass through).
|
||
- **NodeId:** `EquipmentNodeIds.Variable(equipmentId, folderPath, name)` where `folderPath` = collapsed segments joined by `/`. Folders deduped, each parented at its prefix.
|
||
- **DataType:** convert `DriverDataType` → the OPC-UA-builtin string `OtOpcUaNodeManager.EnsureVariable` expects. **Reuse the existing convention** — grep for how `EquipmentTagPlan.DataType` is produced from `DriverDataType` (e.g. a `DriverDataType.ToString()` / a mapping helper) and `OtOpcUaNodeManager.ResolveBuiltInDataType`; do NOT invent a new mapping. If a helper exists, call it; the switch below is a fallback to align if not.
|
||
- **Writable:** from `DiscoveredNode.Writable` (FixedTree is read-only).
|
||
|
||
**Step 1: Write the failing test**
|
||
|
||
```csharp
|
||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||
using Shouldly;
|
||
using Xunit;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||
|
||
[Trait("Category", "Unit")]
|
||
public sealed class DiscoveredNodeMapperTests
|
||
{
|
||
private static DiscoveredNode Node(string[] path, string name, string fullRef,
|
||
DriverDataType dt = DriverDataType.Float64, bool writable = false)
|
||
=> new(path, name, name, fullRef, dt, false, null, writable, false);
|
||
|
||
[Fact]
|
||
public void Maps_under_equipment_collapsing_single_device_folder()
|
||
{
|
||
var nodes = new[]
|
||
{
|
||
Node(["FOCAS", "10.0.0.5:8193", "Identity"], "SeriesNumber", "10.0.0.5:8193/Identity/SeriesNumber", DriverDataType.String),
|
||
Node(["FOCAS", "10.0.0.5:8193", "Axes", "X"], "AbsolutePosition", "10.0.0.5:8193/Axes/X/AbsolutePosition"),
|
||
};
|
||
|
||
var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: []);
|
||
|
||
result.Variables.Select(v => v.NodeId).ShouldBe(new[]
|
||
{
|
||
"EQ-1/FOCAS/Identity/SeriesNumber",
|
||
"EQ-1/FOCAS/Axes/X/AbsolutePosition",
|
||
}, ignoreOrder: true);
|
||
// folders: EQ-1/FOCAS, EQ-1/FOCAS/Identity, EQ-1/FOCAS/Axes, EQ-1/FOCAS/Axes/X
|
||
result.Folders.Select(f => f.NodeId).ShouldContain("EQ-1/FOCAS/Axes/X");
|
||
result.Folders.First(f => f.NodeId == "EQ-1/FOCAS/Axes/X").ParentNodeId.ShouldBe("EQ-1/FOCAS/Axes");
|
||
// routing: driverRef → nodeId
|
||
result.RoutingByRef["10.0.0.5:8193/Identity/SeriesNumber"].ShouldBe("EQ-1/FOCAS/Identity/SeriesNumber");
|
||
result.Variables.First(v => v.NodeId.EndsWith("SeriesNumber")).Writable.ShouldBeFalse();
|
||
}
|
||
|
||
[Fact]
|
||
public void Dedups_authored_refs()
|
||
{
|
||
var nodes = new[]
|
||
{
|
||
Node(["FOCAS", "10.0.0.5:8193"], "parts-count", "parts-count"), // authored
|
||
Node(["FOCAS", "10.0.0.5:8193", "Identity"], "SeriesNumber", "10.0.0.5:8193/Identity/SeriesNumber", DriverDataType.String),
|
||
};
|
||
var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: new HashSet<string> { "parts-count" });
|
||
result.Variables.ShouldHaveSingleItem();
|
||
result.Variables[0].NodeId.ShouldBe("EQ-1/FOCAS/Identity/SeriesNumber");
|
||
}
|
||
|
||
[Fact]
|
||
public void Does_not_collapse_when_two_devices_present()
|
||
{
|
||
var nodes = new[]
|
||
{
|
||
Node(["FOCAS", "10.0.0.5:8193", "Identity"], "SeriesNumber", "a", DriverDataType.String),
|
||
Node(["FOCAS", "10.0.0.6:8193", "Identity"], "SeriesNumber", "b", DriverDataType.String),
|
||
};
|
||
var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: []);
|
||
result.Variables.Select(v => v.NodeId).ShouldBe(new[]
|
||
{
|
||
"EQ-1/FOCAS/10.0.0.5:8193/Identity/SeriesNumber",
|
||
"EQ-1/FOCAS/10.0.0.6:8193/Identity/SeriesNumber",
|
||
}, ignoreOrder: true);
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: Run to verify it fails.**
|
||
|
||
**Step 3: Implement `DiscoveredInjection.cs` (DTOs)**
|
||
|
||
```csharp
|
||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||
|
||
/// <summary>A folder to ensure during discovered-node injection (NodeId + parent + display).</summary>
|
||
public sealed record DiscoveredFolder(string NodeId, string? ParentNodeId, string DisplayName);
|
||
|
||
/// <summary>A read-or-write variable to ensure during discovered-node injection.</summary>
|
||
public sealed record DiscoveredVariable(
|
||
string NodeId, string ParentNodeId, string DisplayName, string DataType, bool Writable, bool IsArray, uint? ArrayLength);
|
||
```
|
||
|
||
**Step 3b: Implement `DiscoveredNodeMapper.cs`**
|
||
|
||
```csharp
|
||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||
|
||
/// <summary>The mapped result of grafting discovered nodes under an equipment.</summary>
|
||
public sealed record DiscoveredInjectionPlan(
|
||
IReadOnlyList<DiscoveredFolder> Folders,
|
||
IReadOnlyList<DiscoveredVariable> Variables,
|
||
IReadOnlyDictionary<string, string> RoutingByRef); // driver FullReference → equipment NodeId
|
||
|
||
/// <summary>
|
||
/// Pure mapper: re-roots a driver's captured discovery tree under an equipment node, deduping
|
||
/// authored Config-DB refs and collapsing the single device-host folder. See the design doc.
|
||
/// </summary>
|
||
public static class DiscoveredNodeMapper
|
||
{
|
||
public static DiscoveredInjectionPlan Map(
|
||
string equipmentId, IReadOnlyList<DiscoveredNode> nodes, ISet<string> authoredRefs)
|
||
{
|
||
var kept = nodes.Where(n => !authoredRefs.Contains(n.FullReference)).ToList();
|
||
|
||
// Collapse a single shared device-folder level (index 1 under the driver root) when present.
|
||
var collapseIndex1 = kept.Count > 0
|
||
&& kept.All(n => n.FolderPathSegments.Count >= 2)
|
||
&& kept.Select(n => n.FolderPathSegments[1]).Distinct(StringComparer.Ordinal).Count() == 1;
|
||
|
||
static IReadOnlyList<string> Effective(IReadOnlyList<string> segs, bool collapse)
|
||
=> collapse ? [segs[0], .. segs.Skip(2)] : segs;
|
||
|
||
var folders = new Dictionary<string, DiscoveredFolder>(StringComparer.Ordinal);
|
||
var variables = new List<DiscoveredVariable>();
|
||
var routing = new Dictionary<string, string>(StringComparer.Ordinal);
|
||
|
||
foreach (var n in kept)
|
||
{
|
||
var segs = Effective(n.FolderPathSegments, collapseIndex1);
|
||
|
||
// Ensure every prefix folder EQ/seg0, EQ/seg0/seg1, …
|
||
for (var i = 0; i < segs.Count; i++)
|
||
{
|
||
var folderPath = string.Join('/', segs.Take(i + 1));
|
||
var nodeId = EquipmentNodeIds.SubFolder(equipmentId, folderPath);
|
||
if (folders.ContainsKey(nodeId)) continue;
|
||
var parent = i == 0 ? equipmentId : EquipmentNodeIds.SubFolder(equipmentId, string.Join('/', segs.Take(i)));
|
||
folders[nodeId] = new DiscoveredFolder(nodeId, parent, segs[i]);
|
||
}
|
||
|
||
var varFolderPath = string.Join('/', segs);
|
||
var varNodeId = EquipmentNodeIds.Variable(equipmentId, varFolderPath, n.BrowseName);
|
||
var varParent = EquipmentNodeIds.SubFolder(equipmentId, varFolderPath);
|
||
variables.Add(new DiscoveredVariable(
|
||
varNodeId, varParent, n.DisplayName, ToBuiltinTypeString(n.DataType), n.Writable, n.IsArray, n.ArrayDim));
|
||
routing[n.FullReference] = varNodeId;
|
||
}
|
||
|
||
return new DiscoveredInjectionPlan(folders.Values.ToList(), variables, routing);
|
||
}
|
||
|
||
// Align with the existing DriverDataType → builtin-string convention used by EquipmentTagPlan /
|
||
// OtOpcUaNodeManager.ResolveBuiltInDataType. VERIFY against that during implementation.
|
||
private static string ToBuiltinTypeString(DriverDataType dt) => dt.ToString();
|
||
}
|
||
```
|
||
|
||
> **Implementation note:** before finalizing `ToBuiltinTypeString`, grep how `EquipmentTagPlan.DataType` is produced from a `DriverDataType` and what strings `OtOpcUaNodeManager.ResolveBuiltInDataType` accepts (e.g. `"Float64"`, `"String"`, `"Int32"`). If `DriverDataType.ToString()` already matches, keep it; otherwise mirror the existing mapping helper. The mapper test asserts NodeIds/structure, not the exact type string — add a focused assertion once the convention is confirmed.
|
||
|
||
**Step 4: Run to verify it passes.**
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/DiscoveredInjection.cs \
|
||
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNodeMapper.cs \
|
||
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveredNodeMapperTests.cs
|
||
git commit -m "feat(otopcua): map discovered nodes under an equipment subfolder"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Node-manager `RaiseNodesAddedModelChange()`
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** Task 1, Task 2
|
||
|
||
**Files:**
|
||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` (add a public method near `BuildNodeShapeChangedEvent:1525`)
|
||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerModelChangeOnAddTests.cs` (model on `NodeManagerSurgicalShapeUpdateTests.cs`)
|
||
|
||
Emit a Part 3 `GeneralModelChangeEvent` with verb `NodeAdded` so already-connected clients can refresh their browse after a runtime add. Mirror the existing `BuildNodeShapeChangedEvent` (verb `DataTypeChanged`) + its `Server.ReportEvent` seam, but build a `NodeAdded` change referencing the equipment subfolder root that gained children.
|
||
|
||
**Step 1: Write the failing test** — instantiate the node manager as the surgical-shape test does, `EnsureFolder` + `EnsureVariable` a couple of nodes, call `RaiseNodesAddedModelChange(parentNodeId)`, and assert it does not throw and (where the harness exposes reported events, as the surgical test does) that a `GeneralModelChangeEvent` with verb `NodeAdded` was reported. Reuse the surgical test's harness/setup verbatim.
|
||
|
||
**Step 2: Run to verify it fails** (method missing).
|
||
|
||
**Step 3: Implement** — add:
|
||
|
||
```csharp
|
||
/// <summary>
|
||
/// Announce that nodes were added at runtime (discovered-node injection) so subscribed clients can
|
||
/// refresh their browse. Part 3 §8.7.4: a GeneralModelChangeEvent is emitted by the Server object;
|
||
/// verb = NodeAdded, affected = the subfolder root that gained children. Mirrors
|
||
/// <see cref="BuildNodeShapeChangedEvent"/>'s ReportEvent seam; tolerant if auditing/eventing is off.
|
||
/// </summary>
|
||
/// <param name="affectedNodeId">The equipment/subfolder NodeId string under which nodes were added.</param>
|
||
public void RaiseNodesAddedModelChange(string affectedNodeId)
|
||
{
|
||
GeneralModelChangeEventState e;
|
||
lock (Lock)
|
||
{
|
||
// BUILD the event under Lock (consistent snapshot of _folders/_variables), mirroring
|
||
// BuildNodeShapeChangedEvent: EventId, SourceNode = ObjectIds.Server, SourceName, Time,
|
||
// Severity, a ModelChangeStructureDataType with Affected = new NodeId(affectedNodeId,
|
||
// NamespaceIndex) + Verb = (byte)ModelChangeStructureVerbMask.NodeAdded, ClearChangeMasks.
|
||
e = BuildNodesAddedModelChange(affectedNodeId);
|
||
}
|
||
// REPORT OUTSIDE Lock — Server.ReportEvent re-enters the server's own subscription/event path;
|
||
// holding Lock across it risks a lock-order inversion (mirror ReportNodeShapeChangedEvent, NOT
|
||
// ReportConditionEvent which uses alarm.ReportEvent). Tolerant: eventing off / no monitored items.
|
||
try { Server.ReportEvent(SystemContext, e); }
|
||
catch (Exception ex)
|
||
{
|
||
#pragma warning disable CS0618
|
||
Utils.LogError(ex, "OtOpcUaNodeManager: failed to report GeneralModelChangeEvent(NodeAdded) for {0}", affectedNodeId);
|
||
#pragma warning restore CS0618
|
||
}
|
||
}
|
||
```
|
||
|
||
> ⚠️ **Lock discipline (corrected 2026-06-26):** BUILD the `GeneralModelChangeEventState` under `lock (Lock)` (copy the field-population block from `BuildNodeShapeChangedEvent` `:1525`, changing only `Verb` → `NodeAdded` and `Affected`), but **REPORT `Server.ReportEvent` OUTSIDE the lock** — exactly like `ReportNodeShapeChangedEvent` / `RevertOptimisticWriteIfNeeded`. `Server.ReportEvent` re-enters the SDK subscription/event path; holding `Lock` across it risks a lock-order-inversion deadlock with a client that has event subscriptions. (An earlier draft of this plan said "keep it inside `lock (Lock)`" — that was wrong for `Server.ReportEvent`; `ReportConditionEvent` is *not* a valid analogue since it uses `alarm.ReportEvent`, the node's own notifier chain.)
|
||
|
||
**Step 4: Run to verify it passes.**
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs \
|
||
tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerModelChangeOnAddTests.cs
|
||
git commit -m "feat(otopcua): GeneralModelChangeEvent(NodeAdded) for runtime node adds"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: `AddressSpaceApplier.MaterialiseDiscoveredNodes(...)`
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** none (depends on Tasks 2, 3)
|
||
|
||
**Files:**
|
||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs` (add after `MaterialiseEquipmentTags:304`)
|
||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs` (add cases)
|
||
|
||
Add an idempotent pass that ensures the mapped folders then variables via the existing `SafeEnsureFolder`/`SafeEnsureVariable`, then raises the model-change. Folders MUST be ensured parent-before-child (sort by NodeId depth / segment count).
|
||
|
||
**Step 1: Write the failing test** — using the applier test's existing fake sink, call `MaterialiseDiscoveredNodes` with 2 folders + 2 read-only variables and assert the sink received `EnsureFolder`/`EnsureVariable` with the right NodeIds/parents, `writable: false`, and that a re-apply is a no-op (idempotent — sink early-returns on existing). Assert `RaiseNodesAddedModelChange` is invoked (extend the fake sink/node-manager double to record it, mirroring how the existing test verifies materialize calls).
|
||
|
||
**Step 2: Run to verify it fails.**
|
||
|
||
**Step 3: Implement**
|
||
|
||
```csharp
|
||
/// <summary>
|
||
/// Materialise driver-discovered nodes (FixedTree) under an equipment at runtime. Idempotent:
|
||
/// re-applies are cheap (the sink's EnsureFolder/EnsureVariable early-return on existing nodes), so
|
||
/// this is safely re-run after every address-space rebuild. Folders are ensured parent-first.
|
||
/// Emits a NodeAdded model-change so connected clients can refresh.
|
||
/// </summary>
|
||
public void MaterialiseDiscoveredNodes(
|
||
string equipmentRootNodeId,
|
||
IReadOnlyList<DiscoveredFolder> folders,
|
||
IReadOnlyList<DiscoveredVariable> variables)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(folders);
|
||
ArgumentNullException.ThrowIfNull(variables);
|
||
if (folders.Count == 0 && variables.Count == 0) return;
|
||
|
||
foreach (var f in folders.OrderBy(f => f.NodeId.Count(c => c == '/')))
|
||
SafeEnsureFolder(f.NodeId, f.ParentNodeId, f.DisplayName);
|
||
|
||
foreach (var v in variables)
|
||
SafeEnsureVariable(v.NodeId, v.ParentNodeId, v.DisplayName, v.DataType, v.Writable,
|
||
historianTagname: null, isArray: v.IsArray, arrayLength: v.ArrayLength);
|
||
|
||
_sink.RaiseNodesAddedModelChange(equipmentRootNodeId);
|
||
|
||
_logger.LogInformation(
|
||
"AddressSpaceApplier: discovered nodes materialised under {Equipment} (folders={Folders}, vars={Vars})",
|
||
equipmentRootNodeId, folders.Count, variables.Count);
|
||
}
|
||
```
|
||
|
||
> Confirm `_sink`'s interface exposes `RaiseNodesAddedModelChange` (the sink type wraps `OtOpcUaNodeManager`); add it to the sink interface if the applier talks to an `IAddressSpaceSink` abstraction rather than the concrete manager.
|
||
|
||
**Step 4: Run to verify it passes.**
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs \
|
||
tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs
|
||
git commit -m "feat(otopcua): applier pass to materialise discovered nodes idempotently"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: `OpcUaPublishActor.MaterialiseDiscoveredNodes` message + handler
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** none (depends on Task 4)
|
||
|
||
**Files:**
|
||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs` (message record near the other records; `Receive<…>` at the block `:217`; handler near `HandleRebuild`)
|
||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs` (add a case)
|
||
|
||
**Step 1: Write the failing test** — with the publish-actor test harness (fake applier), send `MaterialiseDiscoveredNodes(equipmentRoot, folders, variables)` and assert the handler forwards to `_applier.MaterialiseDiscoveredNodes(...)` with the same payload.
|
||
|
||
**Step 2: Run to verify it fails.**
|
||
|
||
**Step 3: Implement** — add the message + Receive + handler:
|
||
|
||
```csharp
|
||
/// <summary>Inject driver-discovered nodes (FixedTree) under an equipment at runtime (post-connect).</summary>
|
||
public sealed record MaterialiseDiscoveredNodes(
|
||
string EquipmentRootNodeId,
|
||
IReadOnlyList<DiscoveredFolder> Folders,
|
||
IReadOnlyList<DiscoveredVariable> Variables);
|
||
```
|
||
|
||
In the Receive block (`:217`, alongside `Receive<RebuildAddressSpace>(HandleRebuild)`):
|
||
|
||
```csharp
|
||
Receive<MaterialiseDiscoveredNodes>(HandleMaterialiseDiscovered);
|
||
```
|
||
|
||
Handler:
|
||
|
||
```csharp
|
||
private void HandleMaterialiseDiscovered(MaterialiseDiscoveredNodes msg)
|
||
=> _applier.MaterialiseDiscoveredNodes(msg.EquipmentRootNodeId, msg.Folders, msg.Variables);
|
||
```
|
||
|
||
**Step 4: Run to verify it passes.**
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs \
|
||
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs
|
||
git commit -m "feat(otopcua): OpcUaPublishActor handles discovered-node materialisation"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: `DriverInstanceActor` post-connect bounded re-discovery
|
||
|
||
**Classification:** high-risk
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none (depends on Task 1; touches actor lifecycle)
|
||
|
||
**Files:**
|
||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs` (message records area `:60-160`; `Connected()` entry via `InitializeSucceeded:278`; new private async discovery method + a self-scheduled retry tick)
|
||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs`
|
||
|
||
On reaching `Connected`, if `_driver is ITagDiscovery`, run discovery into a `CapturingAddressSpaceBuilder`, and `Context.Parent.Tell(new DiscoveredNodesReady(_driverInstanceId, nodes))`. Because FOCAS suppresses FixedTree until `FixedTreeCache` populates (~0–2 s), schedule a bounded retry: re-run every ~2 s up to a cap (~30 s / ~15 attempts) **or until the node count stops growing** (whichever first), then stop. `DiscoverAsync` reads in-memory cache → cheap. Reset/cancel the schedule on leaving `Connected` (DisconnectObserved/ForceReconnect) and re-arm on the next `Connected` entry. Use Akka scheduling (`Context.System.Scheduler.ScheduleTellOnce` self-tell of an internal `RediscoverTick`, tracked by an `ICancelable` so it's cancelled on state exit) — do NOT block the actor thread.
|
||
|
||
**Message records to add** (near the other nested records):
|
||
|
||
```csharp
|
||
/// <summary>Published to the parent (DriverHostActor) after a post-connect discovery pass.</summary>
|
||
public sealed record DiscoveredNodesReady(string DriverInstanceId, IReadOnlyList<DiscoveredNode> Nodes);
|
||
|
||
/// <summary>Internal self-tick driving bounded post-connect re-discovery.</summary>
|
||
private sealed record RediscoverTick(int Generation, int Attempt, int LastCount);
|
||
```
|
||
|
||
**Step 1: Write the failing test** — drive a `DriverInstanceActor` with a fake `IDriver` that also implements `ITagDiscovery`, whose `DiscoverAsync` yields 0 nodes on the first ~2 attempts then a non-empty set (simulating FixedTreeCache populating). Bring the actor to `Connected` (send the same init messages the existing `DriverInstanceActorTests` use). Use the TestKit parent probe (`Context.Parent` → the TestKit `TestActor` via `ActorOf` under the testkit, or the existing harness's parent-probe pattern in `DriverInstanceActorTests`) and `ExpectMsg<DiscoveredNodesReady>` — assert the eventually-delivered message carries the non-empty set, and that re-ticks stop after the set stabilises (no infinite stream). Use the TestKit scheduler / `Within` to advance.
|
||
|
||
**Step 2: Run to verify it fails.**
|
||
|
||
**Step 3: Implement** — add the discovery kick at the `InitializeSucceeded` Connected transition (after `ResubscribeDesired()`), a `Receive<RediscoverTick>` in `Connected()`, and a `RunDiscoveryAsync` that:
|
||
- guards `_driver is ITagDiscovery disc` (else no-op),
|
||
- builds a `CapturingAddressSpaceBuilder`, awaits `disc.DiscoverAsync(builder, ct)`,
|
||
- `Context.Parent.Tell(new DiscoveredNodesReady(_driverInstanceId, builder.Nodes))`,
|
||
- if `attempt < cap` and `builder.Nodes.Count` still growing (or zero), schedules the next `RediscoverTick(_initGeneration, attempt+1, builder.Nodes.Count)` via `ICancelable` (store in a field, cancel on `DetachSubscription`/state exit).
|
||
- Tag ticks with `_initGeneration` and ignore stale-generation ticks (mirrors the existing `InitializeSucceeded.Generation` guard) so a reconnect cancels the prior loop.
|
||
|
||
> Use `ReceiveAsync<RediscoverTick>` (like the other async receives in `Connected()`), and wrap the discovery call in try/catch → log Info + reschedule (bounded). Mirror the existing cancelable-scheduling pattern already used in the actor (grep `Scheduler`/`ICancelable` in this file and `DriverHostActor`).
|
||
|
||
**Step 4: Run to verify it passes.**
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs \
|
||
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs
|
||
git commit -m "feat(otopcua): driver-instance post-connect bounded re-discovery"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: `DriverHostActor` — inject discovered nodes (handler + routing + subscribe)
|
||
|
||
**Classification:** high-risk
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none (depends on Tasks 2, 5, 6; touches actor + routing map)
|
||
|
||
**Files:**
|
||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` (fields near `_nodeIdByDriverRef`; `Receive<…>` in BOTH receive states `:482` and `:512`; new handler; store `_lastComposition` in `PushDesiredSubscriptions`)
|
||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs`
|
||
|
||
Add `Receive<DriverInstanceActor.DiscoveredNodesReady>(HandleDiscoveredNodes)` to the two states that already handle `AttributeValuePublished` (`:484`, `:512`). New fields: `_lastComposition` (set at the end of `PushDesiredSubscriptions`) and `_discoveredByDriver` (`Dictionary<string, DiscoveredInjectionPlan>`). The handler:
|
||
|
||
1. If `_lastComposition` is null → stash nothing / log Debug and return (composition not applied yet; a later `DiscoveredNodesReady` retry will land after apply).
|
||
2. Resolve the equipment: `_lastComposition.EquipmentNodes.Where(e => e.DriverInstanceId == id)`. 0 → log Info skip; >1 → log Warning skip (multi-device deferred). Else take its `EquipmentId`.
|
||
3. Compute `authoredRefs` = `_lastComposition.EquipmentTags.Where(t => t.DriverInstanceId == id).Select(t => t.FullName)` set.
|
||
4. `var plan = DiscoveredNodeMapper.Map(equipmentId, msg.Nodes, authoredRefs);`
|
||
5. If `plan.Variables` empty → return (nothing new yet).
|
||
6. `_discoveredByDriver[id] = plan;`
|
||
7. For each `(ref, nodeId)` in `plan.RoutingByRef`: add to `_nodeIdByDriverRef[(id, ref)]` (the same `HashSet` fan-out structure used in `PushDesiredSubscriptions:1019`).
|
||
8. `_opcUaPublishActor.Tell(new OpcUaPublishActor.MaterialiseDiscoveredNodes(equipmentId, plan.Folders, plan.Variables));`
|
||
9. Merge the discovered refs into the driver's desired set and re-push: `child.Actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(union, SubscriptionPublishingInterval, alarmRefs))` where `union` = authored refs already pushed for that driver **plus** `plan.RoutingByRef.Keys`. (Keep the alarmRefs as last pushed.) The actor's `Connected` `SetDesiredSubscriptions` handler immediately re-subscribes (`:340-353`).
|
||
|
||
**Step 1: Write the failing test** — build a `DriverHostActor` via its existing test harness (`DriverHostActorTests`/`...WriteRoutingTests` show construction with fakes: a fake child/registry, fake OPC publish probe, a composition artifact). Apply a deployment whose composition has one equipment (`EQ-1`, `DriverInstanceId=d1`) + one authored tag, so `_lastComposition` is set and a child `d1` exists. Send `DriverInstanceActor.DiscoveredNodesReady("d1", <fixedtree nodes>)`. Assert: (a) the OPC publish probe received `MaterialiseDiscoveredNodes` with the mapped folders/vars; (b) the child probe received a `SetDesiredSubscriptions` whose refs include both the authored ref and the FixedTree refs; (c) a subsequent `AttributeValuePublished(d1, <fixedtree ref>, value)` routes to an `AttributeValueUpdate` at the mapped NodeId (proves `_nodeIdByDriverRef` updated).
|
||
|
||
**Step 2: Run to verify it fails.**
|
||
|
||
**Step 3: Implement** per the steps above. Store `_lastComposition = composition;` at the end of `PushDesiredSubscriptions` (after the existing logic). Reuse the exact fan-out add pattern for `_nodeIdByDriverRef` from `:1019-1045`.
|
||
|
||
**Step 4: Run to verify it passes.**
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs \
|
||
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs
|
||
git commit -m "feat(otopcua): inject discovered nodes into the equipment projection on connect"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: `DriverHostActor` — re-inject discovered nodes after a rebuild
|
||
|
||
**Classification:** high-risk
|
||
**Estimated implement time:** ~3 min
|
||
**Parallelizable with:** none (depends on Task 7; same file)
|
||
|
||
**Files:**
|
||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` (tail of `PushDesiredSubscriptions`)
|
||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs` (add a case)
|
||
|
||
A structural redeploy triggers `RebuildAddressSpace` (full teardown) and `PushDesiredSubscriptions` rebuilds `_nodeIdByDriverRef` from authored tags only — losing the injected FixedTree nodes + mappings. After the existing `PushDesiredSubscriptions` work, **re-apply the cached `_discoveredByDriver`**: for each cached plan, re-add its `RoutingByRef` to `_nodeIdByDriverRef`, re-`Tell` `MaterialiseDiscoveredNodes`, and re-merge its refs into that driver's pushed `SetDesiredSubscriptions`.
|
||
|
||
**Step 1: Write the failing test** — after Task 7's injection, simulate a second `PushDesiredSubscriptions` (re-apply the same deployment). Assert the OPC publish probe receives `MaterialiseDiscoveredNodes` AGAIN and the child's re-pushed `SetDesiredSubscriptions` still includes the FixedTree refs (i.e. they weren't dropped by the rebuild).
|
||
|
||
**Step 2: Run to verify it fails** (today the rebuild drops them).
|
||
|
||
**Step 3: Implement** — extract the per-driver merge-and-materialise into a helper reused by both `HandleDiscoveredNodes` and a new `ReapplyDiscovered()` call at the tail of `PushDesiredSubscriptions` (after `_lastComposition` is set). Guard for the case where the driver no longer exists in `_children` or the equipment was removed (drop that cache entry).
|
||
|
||
**Step 4: Run to verify it passes.**
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs \
|
||
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs
|
||
git commit -m "feat(otopcua): re-inject discovered nodes after address-space rebuild"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Integration test — discovered nodes appear + carry values + survive lifecycle
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none (depends on Tasks 7, 8)
|
||
|
||
**Files:**
|
||
- Create: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs`
|
||
- (Reuse / extend any existing in-memory `IDriver` test double in the Runtime tests; create a `FakeDiscoverableDriver : IDriver, ITagDiscovery, ISubscribable` if none fits.)
|
||
|
||
A focused in-process integration test (no docker, no CNC): wire `DriverHostActor` + `OpcUaPublishActor` + a real `AddressSpaceApplier`/node manager (as the publish-actor rebuild tests do) + a fake discoverable+subscribable driver whose `DiscoverAsync` exposes a delayed FixedTree set and whose poll returns values for those refs. Assert end-to-end:
|
||
1. After connect + the discovery delay, the node manager has variables at `EQ-…/FOCAS/…`.
|
||
2. A poll value for a FixedTree ref surfaces as a Good `AttributeValueUpdate` at the mapped NodeId (no longer `BadWaitingForInitialData`).
|
||
3. After a simulated rebuild (re-apply), the nodes + values persist.
|
||
|
||
> If a full wiring proves too heavy for one test fixture, split into (9a) host→publish materialisation reaching a real node manager, and (9b) value-route smoke — but keep both in this file. Do NOT silently drop the lifecycle assertion; if you cannot wire a real node manager here, log that limitation in the test summary and cover it in Task 10's docker-dev step instead.
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
git add tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs
|
||
git commit -m "test(otopcua): end-to-end discovered-node injection + value flow"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Build + full suite + docker-dev smoke
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none (depends on all prior)
|
||
|
||
**Files:** none (verification only; fix wiring if the build/tests surface gaps)
|
||
|
||
**Steps:**
|
||
1. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → 0 errors, 0 warnings.
|
||
2. `dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~Runtime.Tests"` → green.
|
||
3. `dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~OpcUaServer.Tests"` → green.
|
||
4. `dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~FOCAS"` → green (no regression).
|
||
5. **docker-dev smoke (optional but recommended):** build the docker-dev image, boot `central-1` (fused admin+driver), confirm via logs that a connected discoverable driver injects nodes (`AddressSpaceApplier: discovered nodes materialised`) and that browse shows `EQ-…/FOCAS/…`. (Mirror the symptom-#1 docker-dev confirmation in the investigation plan.)
|
||
6. Commit any wiring fixes with a `fix(otopcua):` message.
|
||
|
||
---
|
||
|
||
## Task 11: Docs
|
||
|
||
**Classification:** trivial
|
||
**Estimated implement time:** ~3 min
|
||
**Parallelizable with:** none (depends on Task 10)
|
||
|
||
**Files:**
|
||
- Modify: `docs/plans/2026-06-25-otopcua-equipment-dataplane-investigation.md` (mark symptom #2 / the FixedTree feature done, link this plan + the design doc)
|
||
- Modify: `docs/deployments/wonder-app-vd03-makino-z-34184.md` (note FixedTree now surfaces under `EQ-…/FOCAS/…`)
|
||
- Modify: `docs/plans/2026-06-26-otopcua-fixedtree-equipment-injection-design.md` (status → Implemented)
|
||
|
||
**Step: Commit**
|
||
|
||
```bash
|
||
git add docs/plans/2026-06-25-otopcua-equipment-dataplane-investigation.md \
|
||
docs/deployments/wonder-app-vd03-makino-z-34184.md \
|
||
docs/plans/2026-06-26-otopcua-fixedtree-equipment-injection-design.md
|
||
git commit -m "docs(otopcua): record FixedTree-under-Equipment injection feature"
|
||
```
|
||
|
||
---
|
||
|
||
## Live deploy (post-plan, with user confirmation)
|
||
|
||
Following the symptom-#1 pattern (self-contained publish-overlay → wonder): after the suite is green and docker-dev confirms, **confirm with the user before deploying to the production CNC node**, then deploy and browse `EQ-3686c0272279/FOCAS/Identity/SeriesNumber` + `…/Axes/X/AbsolutePosition` (assert status Good — values may be 0 on the idle machine). Live deploy is explicitly NOT part of the build/test gate.
|
||
|
||
## Follow-ups (out of scope, documented)
|
||
|
||
- Discovered **alarms** injection; **multi-device-per-driver-instance** equipment mapping; **writable** discovered nodes.
|
||
- Reconcile the AdminUI↔driver FOCAS config-format mismatch (series-as-number, scheme-less host) at the AdminUI source.
|
||
- Shared `AddZbSerilog` not setting static `Serilog.Log.Logger` (latent across all 3 apps).
|