Files
lmxopcua/docs/plans/2026-06-26-otopcua-fixedtree-followups.md
T

29 KiB
Raw Blame History

FixedTree-injection follow-ups — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (or executing-plans) to implement this plan task-by-task.

Goal: Implement the five approved follow-ups to the FixedTree-under-Equipment dynamic-injection feature: (A) injectable discovery timeout, (B) per-driver re-discovery policy gate, (C) re-trigger discovery on a config-unchanged rebind, (D) de-dup the double SetDesiredSubscriptions, and (E) lift the ≥1-authored-tag requirement + support multi-device-per-driver.

Architecture: Akka.NET actor pipeline. DriverInstanceActor runs post-connect discovery and publishes DiscoveredNodesReady; DriverHostActor resolves the bound equipment, maps discovered nodes via DiscoveredNodeMapper, caches a plan, materialises via OpcUaPublishActor, and merges subscription refs. Composition is built by AddressSpaceComposer.Compose (pure, from entities) and mirrored by DeploymentArtifact (decode, from the sealed JSON artifact) — the two MUST stay byte-parity-equal. The deployment artifact already serialises full Equipment + Device entities, so E needs no DB migration and no artifact wire-format change — only decode/projection reads.

Tech Stack: .NET 10, C# (default interface members, collection expressions), Akka.NET, xUnit. Build: dotnet build ZB.MOM.WW.OtOpcUa.slnx (TreatWarningsAsErrors). Test (macOS — run filtered, NOT full-solution; the net48 Wonderware testhost can't run on macOS):

  • dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~Runtime.Tests"
  • dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~OpcUaServer.Tests"
  • dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~FOCAS"

Design: 2026-06-26-otopcua-fixedtree-followups-design.md. Branch: feat/focas-fixedtree-equipment-injection (continue on it; commit per task; do NOT push/merge — standing rule).

Out of scope (locked): discovered-alarm injection; writable discovered nodes.


Execution order & parallelism

Two files are each touched by multiple tasks and MUST be edited serially:

  • DriverInstanceActor.cs: Task 1 → Task 3 → Task 4
  • DriverHostActor.cs: Task 6 → Task 7 → Task 8 → Task 9

Independent file sets that can run concurrently with the above: Task 2 (ITagDiscovery + 5 driver files) and Task 5 (AddressSpaceComposer.cs + DeploymentArtifact.cs).

Dependency summary: T3 ⟵{T1,T2}; T4 ⟵T3; T6 ⟵T5; T7 ⟵{T4,T6}; T8 ⟵T7; T9 ⟵{T5,T8}; T10 ⟵T9; T11 ⟵{T2,T4,T9,T10}.


Task 1: Injectable discovery timeout (follow-up A)

Classification: small Estimated implement time: ~3 min Parallelizable with: Task 2, Task 5

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs (ctor ~244-259, Props ~195-210, fields ~133-137, HandleRediscoverAsync ~765)
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs

Context: HandleRediscoverAsync hardcodes using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); (line 765). The rediscover interval + attempt-cap are already ctor params (_rediscoverInterval, _rediscoverMaxAttempts). Add a sibling param for the per-pass discovery timeout, default-preserving.

Step 1 — Failing test: add a test asserting that when constructed with a very short discovery timeout and an ITagDiscovery whose DiscoverAsync blocks, the pass cancels by the injected timeout (e.g. DiscoveredNodesReady carries an empty set within the short window) rather than waiting 30 s. Reuse the existing fake ITagDiscovery driver in this test file (search it for the existing discovery-actor fake; mirror that pattern). If a fully deterministic timeout test is too flaky, instead assert the wiring: a new public DefaultRediscoverDiscoverTimeout constant exists and equals 30 s, and the ctor/Props accept the param.

Step 2 — Verify it fails: dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~DriverInstanceActorDiscoveryTests" → fails to compile / fails assertion.

Step 3 — Implement:

  • Add public static readonly TimeSpan DefaultRediscoverDiscoverTimeout = TimeSpan.FromSeconds(30); next to the other discovery defaults (~line 36-39).
  • Add a field private readonly TimeSpan _rediscoverDiscoverTimeout; (~133-137).
  • Add ctor param TimeSpan? rediscoverDiscoverTimeout = null (after rediscoverMaxAttempts); assign _rediscoverDiscoverTimeout = rediscoverDiscoverTimeout ?? DefaultRediscoverDiscoverTimeout;.
  • Add the matching optional param to Props and forward it.
  • In HandleRediscoverAsync, replace TimeSpan.FromSeconds(30) with _rediscoverDiscoverTimeout.

Step 4 — Verify: test passes; dotnet build ZB.MOM.WW.OtOpcUa.slnx → 0 warnings.

Step 5 — Commit: git commit -m "feat(otopcua): make FixedTree re-discovery per-pass timeout injectable (follow-up A)"


Task 2: Re-discovery policy enum + ITagDiscovery member + driver overrides (follow-up B, part 1)

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 1, Task 5

Files:

  • Modify: src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs (or a small new test next to the FOCAS driver tests asserting FocasDriver reports UntilStable)

Context: ITagDiscovery (Core.Abstractions) currently has only DiscoverAsync. Add a policy the actor (Task 3) honors. Default = today's behavior so any non-overriding driver is unchanged.

Step 1 — Failing test: assert new FocasDriver(...).RediscoverPolicy == DiscoveryRediscoverPolicy.UntilStable and that one network driver (e.g. OpcUaClientDriver) reports Once. (Construct via the simplest available ctor/fake; if drivers are hard to construct standalone, assert the enum + default member exist and compile, plus a focused test on FOCAS.)

Step 2 — Verify it fails: compile failure (enum/member absent).

Step 3 — Implement:

  • In ITagDiscovery.cs, add the enum + a default-implemented member:
/// <summary>How aggressively the host re-runs post-connect discovery for this driver.</summary>
public enum DiscoveryRediscoverPolicy
{
    /// <summary>Retry every interval up to the cap or until the captured set is non-empty and stable
    /// (for drivers whose discovered shape fills in asynchronously after connect, e.g. FOCAS FixedTree).</summary>
    UntilStable,
    /// <summary>Run exactly one discovery pass on connect (drivers that discover synchronously in DiscoverAsync).</summary>
    Once,
    /// <summary>Never run post-connect discovery.</summary>
    Never,
}

public interface ITagDiscovery
{
    /// <summary>Post-connect re-discovery policy. Default preserves the original retry-until-stable behavior.</summary>
    DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.UntilStable;

    Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken);
}
  • FocasDriver: add public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.UntilStable; (explicit — it genuinely needs the retry loop).
  • OpcUaClientDriver, TwinCATDriver, AbCipDriver, AbLegacyDriver: add public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once; — these discover synchronously inside DiscoverAsync, so one pass on connect suffices; the 15× retry was wasted (potentially heavy) work. Before setting Once, confirm each driver's DiscoverAsync returns its complete set synchronously (read each DiscoverAsync); if any populates a cache asynchronously after connect like FOCAS, leave it UntilStable and note why in a comment.

Step 4 — Verify: test passes; build 0 warnings.

Step 5 — Commit: git commit -m "feat(otopcua): add ITagDiscovery.RediscoverPolicy + per-driver assignments (follow-up B)"


Task 3: DriverInstanceActor honors RediscoverPolicy (follow-up B, part 2)

Classification: standard Estimated implement time: ~5 min Parallelizable with: none (serial after Task 1 on the same file; needs Task 2's enum)

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs (StartDiscovery ~736-740, HandleRediscoverAsync ~754-795)
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs

Context: StartDiscovery() currently kicks the loop for every ITagDiscovery driver. HandleRediscoverAsync schedules the next tick unless stable/capped. Gate both on the driver's RediscoverPolicy.

Step 1 — Failing tests (3):

  1. A fake ITagDiscovery driver reporting Never → no DiscoveredNodesReady is ever published after connect.
  2. A fake reporting Once whose captured set would keep GROWING across passes → exactly ONE DiscoveredNodesReady and no further tick scheduled.
  3. A fake reporting UntilStable → existing behavior (retries until stable/cap) — keep/extend the current passing test.

Step 2 — Verify they fail: the Never/Once tests fail (today everything retries-until-stable).

Step 3 — Implement:

  • In StartDiscovery(): after the if (_driver is not ITagDiscovery discovery) return; guard, read the policy; if (discovery.RediscoverPolicy == DiscoveryRediscoverPolicy.Never) return; before scheduling the first RediscoverTick.
  • In HandleRediscoverAsync: after publishing DiscoveredNodesReady, when the policy is Once, do NOT schedule another tick (log Debug "policy=Once, single pass" and return). When UntilStable, keep today's stop-on-stable + cap logic. (Read the live policy via ((ITagDiscovery)_driver).RediscoverPolicy.)
  • Keep the generation guard intact.

Step 4 — Verify: the 3 tests pass; the full DriverInstanceActorDiscoveryTests + Runtime.Tests suite stays green; build 0 warnings.

Step 5 — Commit: git commit -m "feat(otopcua): DriverInstanceActor honors RediscoverPolicy (Never/Once/UntilStable) (follow-up B)"


Task 4: TriggerRediscovery message + handler (follow-up C, part 1)

Classification: standard Estimated implement time: ~4 min Parallelizable with: none (serial after Task 3 on the same file)

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs (message decls near RediscoverTick ~110-115; add a Connected-state receive)
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs

Context: Task 7 (DriverHostActor) will Tell a driver child to re-run discovery after a rebind. The child must accept that message and only act when Connected.

Step 1 — Failing tests (2):

  1. Send TriggerRediscovery to an actor whose driver is Connected → it runs a discovery pass and publishes DiscoveredNodesReady (respecting policy: a Never driver does NOT).
  2. Send TriggerRediscovery before connect / while not Connected → no DiscoveredNodesReady, no crash (no-op).

Step 2 — Verify they fail: message type doesn't exist.

Step 3 — Implement:

  • Add public sealed record TriggerRediscovery(); near the other public messages.
  • In the Connected state, add a receive for TriggerRediscovery that calls StartDiscovery() (which already honors policy + the ITagDiscovery guard, and uses the current _initGeneration).
  • In other states, either don't register the receive (so it's unhandled = no-op) or register a no-op. Prefer registering only in Connected so a non-connected child silently ignores it (verify the actor's state-machine style — match how other state-scoped messages are handled). Ensure no Unhandled-logging noise; if the actor logs unhandled messages, add an explicit ignore in the relevant states.

Step 4 — Verify: both tests pass; suite green; build 0 warnings.

Step 5 — Commit: git commit -m "feat(otopcua): DriverInstanceActor.TriggerRediscovery message (follow-up C)"


Task 5: EquipmentNode carries DriverInstanceId/DeviceId/DeviceHost (follow-up E, projection)

Classification: high-risk Estimated implement time: ~5 min Parallelizable with: Task 1, Task 2

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs (EquipmentNode record line 61; projection ~326-332; Compose signatures ~281-312)
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs (ReadEquipmentNode ~810-820; the equipment decode call ~204; Empty() ~362-367; add a Devices-array → DeviceId→host map)
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ (composer projection test) and the existing artifact-decode/parity test for EquipmentNode (search tests/ for ReadEquipmentNode/EquipmentNodes/DeploymentArtifact coverage; if a Compose-vs-decode parity test exists, extend it)

Context: The artifact already serialises full Equipment rows (incl. nullable DriverInstanceId, DeviceId) and a full Devices array (each Device has DeviceId + schemaless DeviceConfig JSON containing FOCAS's HostAddress). Compose (pure) and DeploymentArtifact (decode) MUST produce identical EquipmentNodes. _lastComposition (used by the resolver) always comes from decode, but parity is still required by tests.

Step 1 — Failing tests:

  • Composer: given an Equipment with DriverInstanceId="d1", DeviceId="dev1", and a Device{DeviceId="dev1", DeviceConfig={"HostAddress":"10.0.0.5:8193"}}, Compose(...) yields EquipmentNode with DriverInstanceId=="d1", DeviceId=="dev1", DeviceHost=="10.0.0.5:8193"; with no device assigned → all three null.
  • Decode: an artifact JSON whose Equipment element has those fields + a matching Devices element decodes to the same EquipmentNode.

Step 2 — Verify they fail: EquipmentNode has no such fields.

Step 3 — Implement:

  • Extend the record (defaulted params keep all existing call sites compiling):
public sealed record EquipmentNode(
    string EquipmentId,
    string DisplayName,
    string UnsLineId,
    string? DriverInstanceId = null,
    string? DeviceId = null,
    string? DeviceHost = null);
  • Add a shared host-extraction helper usable by BOTH sides (place it where both can call it without a new project dependency — e.g. a public static string? TryExtractDeviceHost(string? deviceConfigJson) on AddressSpaceComposer, parsing the top-level "HostAddress" string from the DeviceConfig JSON; return null if absent/unparseable). Add a normalization step (trim; lower-case host) and DOCUMENT that the discovered device-host folder segment must be normalized the same way in Task 9.
  • Compose: add an optional IReadOnlyList<Device>? devices = null param to BOTH overloads (forward from the 5-arg overload as empty). Build deviceHostById = devices.ToDictionary(d => d.DeviceId, d => TryExtractDeviceHost(d.DeviceConfig)). In the equipment projection, set DriverInstanceId: e.DriverInstanceId, DeviceId: e.DeviceId, DeviceHost: e.DeviceId is null ? null : deviceHostById.GetValueOrDefault(e.DeviceId).
  • DeploymentArtifact: read the Devices array (decode DeviceId + DeviceConfig) into a DeviceId→host map using the SAME TryExtractDeviceHost helper; thread it into ReadEquipmentNode (change its signature to accept the map, or do a post-pass) so it reads DriverInstanceId/DeviceId from the element and resolves DeviceHost from the map. Update Empty() only if its arity changed (it won't — record params are defaulted).
  • Parity: ensure the decode-side host normalization is byte-identical to Compose's (same helper). If a Compose-vs-decode parity test exists, pass the same Devices to Compose in that test.

Step 4 — Verify: new tests pass; OpcUaServer.Tests + Runtime.Tests green; build 0 warnings. Run the existing artifact-parity test — it MUST stay green.

Step 5 — Commit: git commit -m "feat(otopcua): EquipmentNode carries DriverInstanceId/DeviceId/DeviceHost (follow-up E projection)"


Task 6: DriverHostActor — cache-as-dict + driver-level equipment resolution (follow-up E, part 1)

Classification: high-risk Estimated implement time: ~5 min Parallelizable with: none (serial: first DriverHostActor task; needs Task 5)

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs (_discoveredByDriver field ~168; HandleDiscoveredNodes ~580-639; ApplyDiscoveredPlan ~658-701; RoutingEquals; redeploy re-inject tail ~1247-1290)
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs

Context: Today _discoveredByDriver is Dictionary<string, DiscoveredInjectionPlan> (one plan per driver) and equipment is resolved ONLY from authored EquipmentTags. This task (1) changes the cache value to a per-equipment map so Task 9 can add multiple equipments, and (2) makes resolution also use the equipment-level driver link so a driver with an assigned equipment but ZERO authored tags still grafts. Still requires exactly one resolved equipment here (multi-device is Task 9) — >1 keeps the current warn+skip.

Step 1 — Failing tests:

  • Tag-less graft: composition has an EquipmentNode{DriverInstanceId="d1"} with NO authored EquipmentTags for d1; DiscoveredNodesReady("d1", nodes) → nodes graft under that equipment (today: skipped with "no equipment/authored tags").
  • Regression: the existing single-equipment-with-authored-tags test still grafts identically (collapse retained).

Step 2 — Verify it fails: tag-less case is skipped today.

Step 3 — Implement:

  • Change _discoveredByDriver to Dictionary<string, IReadOnlyDictionary<string, DiscoveredInjectionPlan>> (driverId → (equipmentId → plan)). Update ALL readers: HandleDiscoveredNodes short-circuit, ApplyDiscoveredPlan, and the redeploy re-inject tail must iterate the inner map.
  • New resolution in HandleDiscoveredNodes: candidate equipments = _lastComposition.EquipmentNodes.Where(e => e.DriverInstanceId == driverId).Select(e => e.EquipmentId) the existing authored-tag-derived set. Distinct.
    • 0 → log Info, skip (unchanged message).
    • 1 → resolve equipmentId; authoredRefs for that driver as today; DiscoveredNodeMapper.Map(equipmentId, nodes, authoredRefs); cache as a 1-entry inner map; apply.
    • 1 → for THIS task, keep _log.Warning(... "multi-equipment-per-driver is handled in the multi-device path") + skip. (Task 9 replaces this branch.)

  • ApplyDiscoveredPlan: keep applying a single (equipmentId, plan); callers now iterate the inner map and call it per entry. The subscription-merge union must include ALL discovered routing keys across the driver's plans (so a multi-plan driver subscribes every device's refs). Keep the authored value/alarm ref computation.
  • RoutingEquals short-circuit: compare the FULL new inner-map routing against the cached inner-map routing (skip re-apply only when every equipment's routing is unchanged).
  • Redeploy re-inject tail: iterate _discoveredByDriver; for each driver, re-resolve candidates from the CURRENT composition; per cached (equipmentId, plan) entry, keep the existing drop rules (equipment no longer resolves / plan NodeIds not scoped to equipmentId) but applied per-entry; re-apply surviving entries. (Task 7 will add the re-trigger on drop; Task 8 the de-dup.)

Step 4 — Verify: new + existing DriverHostActorDiscoveryTests green; Runtime.Tests green; build 0 warnings.

Step 5 — Commit: git commit -m "feat(otopcua): driver-level equipment resolution + per-equipment discovered-plan cache (follow-up E)"


Task 7: DriverHostActor — re-trigger discovery on rebind drop (follow-up C, part 2)

Classification: high-risk Estimated implement time: ~4 min Parallelizable with: none (serial after Task 6; needs Task 4's message)

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs (redeploy re-inject tail drop branches ~1264-1288; update the deliberate-won't-fix comment)
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs

Context: When the re-inject tail DROPS a cached plan because the equipment rebound/no-longer-resolves, the FixedTree stays absent under the new equipment until the driver's next natural reconnect. Re-trigger discovery so it re-grafts promptly.

Step 1 — Failing test: simulate a redeploy where a driver's equipment changed (cached plan scoped to old EQ-1, new composition binds the driver to EQ-2). Assert the driver child receives DriverInstanceActor.TriggerRediscovery after the drop. (Use the test harness's child-probe/TestProbe pattern already used in this file for asserting messages to driver children.)

Step 2 — Verify it fails: no re-trigger today.

Step 3 — Implement: in each drop branch (the two Remove sites), after removing the entry, Tell that driver's child actor new DriverInstanceActor.TriggerRediscovery() (guard: only if the child exists in _children). Update the inline comment: the previous "we deliberately do NOT add re-trigger logic" note becomes a description of the new re-trigger (discovery-only, idempotent, child no-ops if not Connected). If a driver maps to MULTIPLE cached equipment entries and only one drops, still send a single TriggerRediscovery (discovery re-resolves all of them) — de-dupe so a driver is told at most once per re-inject pass.

Step 4 — Verify: test passes; suite green; build 0 warnings.

Step 5 — Commit: git commit -m "feat(otopcua): re-trigger discovery on config-unchanged rebind (follow-up C)"


Task 8: DriverHostActor — single SetDesiredSubscriptions per redeploy (follow-up D)

Classification: high-risk Estimated implement time: ~5 min Parallelizable with: none (serial after Task 7)

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs (PushDesiredSubscriptions bulk loop ~1204; re-inject tail interaction)
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs

Context: During an in-process redeploy a cached driver gets the bulk authored-only SetDesiredSubscriptions (line 1204) AND then the union from ApplyDiscoveredPlan (line 697) — one extra unsub/resub blip. Make it exactly one send per driver.

Step 1 — Failing test: redeploy with one driver that has a cached discovered plan; assert the driver child receives SetDesiredSubscriptions EXACTLY ONCE during the redeploy, and that the single payload is the authoreddiscovered UNION. Add a second test: a driver whose cached plan is DROPPED in the re-inject tail (rebind) still receives exactly one SetDesiredSubscriptions carrying the AUTHORED-ONLY set (fallback) — its authored subscriptions must not be lost.

Step 2 — Verify it fails: today the cached-driver case sends twice.

Step 3 — Implement:

  • In the bulk loop, SKIP the send for any driverId present in _discoveredByDriver (capture the key set BEFORE the re-inject tail runs).
  • Re-inject tail: when a cached plan is APPLIED, ApplyDiscoveredPlan already sends the union (covers authored). When a cached plan is DROPPED (all entries for the driver removed → the driver no longer has any cached plan), send the authored-only SetDesiredSubscriptions for that driver as a fallback (mirror the bulk-loop payload: authored value refs + alarm refs, SubscriptionPublishingInterval).
  • Ensure the invariant holds for drivers WITHOUT a cached plan (unchanged: single bulk send) and drivers added/removed by the reconcile.

Step 4 — Verify: both tests pass; the existing redeploy/restore tests stay green (watch for any test asserting the old double-send count); build 0 warnings.

Step 5 — Commit: git commit -m "perf(otopcua): one SetDesiredSubscriptions per driver per redeploy (follow-up D)"


Task 9: DriverHostActor — multi-device-per-driver partition (follow-up E, part 2)

Classification: high-risk Estimated implement time: ~5 min Parallelizable with: none (serial after Task 8; needs Task 5's DeviceHost)

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs (HandleDiscoveredNodes >1-candidate branch from Task 6)
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs

Context: Replace Task 6's ">1 candidate → warn+skip" with a real partition. Each candidate equipment has EquipmentNode.DeviceHost (from Task 5). The discovered nodes carry a device-host folder segment at FolderPathSegments[1] (FOCAS uses device.HostAddress). Partition nodes by that segment, normalize it the SAME way Task 5 normalized DeviceHost, and map each device's subset under the matching equipment via the existing DiscoveredNodeMapper.Map (a single-device subset → collapse kicks in per equipment → clean EQ-n/FOCAS/Identity/...).

Step 1 — Failing tests:

  • Multi-device: driver d1 resolves to EQ-A{DeviceHost=h1} and EQ-B{DeviceHost=h2}; discovered nodes split across folder segments h1/h2; assert h1's subtree grafts under EQ-A and h2's under EQ-B, each routing-keyed correctly, and _discoveredByDriver["d1"] has two entries.
  • Unmatched device-host → warn-skip: a discovered segment h3 with no matching equipment is NOT grafted (logged Warning), while h1/h2 still graft.
  • Degenerate: >1 candidate but NO DeviceHost data anywhere → falls back to warn+skip (no crash, no mis-graft).

Step 2 — Verify it fails: Task 6 left this as warn+skip.

Step 3 — Implement: in the >1-candidate branch, build hostToEquipment = candidates.Where(e => e.DeviceHost != null).ToDictionary(Normalize(e.DeviceHost), e.EquipmentId) (guard duplicate hosts → warn+skip the ambiguous host). Partition nodes by Normalize(FolderPathSegments.Count >= 2 ? FolderPathSegments[1] : null). For each partition with a matching equipment: compute that equipment's authoredRefs, Map(equipmentId, partitionNodes, authoredRefs), collect into the inner (equipmentId → plan) map. Unmatched partitions → _log.Warning + skip. Cache the multi-entry inner map and apply every entry (Task 6 made apply per-entry). Use the SAME normalization helper from Task 5 (factor it so both call it).

Step 4 — Verify: all three tests pass; single-device + tag-less tests from Task 6 still green; Runtime.Tests + OpcUaServer.Tests + FOCAS suites green; build 0 warnings.

Step 5 — Commit: git commit -m "feat(otopcua): multi-device-per-driver FixedTree partition (follow-up E)"


Task 10: Docs — update follow-up notes + design statuses

Classification: trivial Estimated implement time: ~3 min Parallelizable with: none (after Task 9)

Files:

  • Modify: docs/plans/2026-06-26-otopcua-fixedtree-equipment-injection-design.md (the "Follow-ups surfaced during the review chain" section + the decisions-table multi-device row — mark AE DONE, note the rebind re-trigger now exists)
  • Modify: docs/plans/2026-06-26-otopcua-fixedtree-followups-design.md (Status → Implemented)
  • Modify: docs/plans/2026-06-26-otopcua-fixedtree-equipment-injection-RESUME.md (§3 — strike the now-closed follow-ups)

Steps: update the prose to reflect what shipped (each follow-up + the fact that E required no migration / no artifact change; the rebind re-trigger reversed the earlier won't-fix, cleanly). Commit: git commit -m "docs(otopcua): record FixedTree follow-ups A-E as implemented"


Task 11: Build + full offline suite + regression gate

Classification: standard Estimated implement time: ~4 min (mostly test wall-time) Parallelizable with: none (final; after Tasks 2, 4, 9, 10)

Files: none (verification only)

Steps:

  1. dotnet build ZB.MOM.WW.OtOpcUa.slnx0 errors, 0 warnings.
  2. dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~Runtime.Tests" → all green.
  3. dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~OpcUaServer.Tests" → all green.
  4. dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~FOCAS" → all green (live-wire integration tests skip without the CNC — expected).
  5. Confirm the validated single-device FOCAS injection path is unchanged (the relevant DriverHostActorDiscoveryTests/end-to-end test passes untouched). Report counts. Do NOT run a full-solution dotnet test (net48 Wonderware testhost can't run on macOS).

No commit (verification). Live wonder re-validation is optional + user-gated.