Files
lmxopcua/docs/plans/2026-06-13-opcuaclient-factory-plan.md
T

10 KiB

OpcUaClient Driver Factory — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.

Goal: Register the OpcUaClient driver factory so DriverType="OpcUaClient" instances are constructed (not stubbed) — fixing a real bug and yielding the first end-to-end live equipment-tag value (live-proving the FullName→NodeId router shipped in c4435e4f).

Architecture: Mirror ModbusDriverFactoryExtensions: a new OpcUaClientDriverFactoryExtensions (Register + CreateInstance that deserialises OpcUaClientDriverOptions exactly as the existing probe does) plus one registration line in DriverFactoryBootstrap.Register. OpcUaClient is cross-platform, so once a factory exists it runs as a real driver in docker-dev.

Tech Stack: .NET 10, System.Text.Json, OPC UA Foundation stack, xUnit + Shouldly. Design: docs/plans/2026-06-13-opcuaclient-factory-design.md (master 5aa1030b).

Branch: feat/opcuaclient-factory off master 5aa1030b.


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, or current.md.
  • Never echo secrets. No force-push, no --no-verify. No Configuration/EF migration change.

Task order

T0 branch → T1 factory + register + unit test → T2 verify (build + test + live /run).


Task 0: Create feature branch

Classification: trivial Estimated implement time: ~1 min Parallelizable with: none

Files: git only

git checkout master && git rev-parse --short HEAD   # expect 5aa1030b
git checkout -b feat/opcuaclient-factory

Confirm clean tree (ignore untracked pending.md / current.md / sql_login.txt / pki/).


Task 1: OpcUaClientDriverFactoryExtensions + register it + unit test

Classification: standard Estimated implement time: ~5 min Parallelizable with: none

Files:

  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverFactoryExtensions.cs
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs (the Register method, ~lines 100-106)
  • Test: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDriverFactoryTests.cs

Context: Read src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs as the template (its Register + CreateInstance + JsonOptions), and src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverProbe.cs (it already deserialises the config). Confirm the OpcUaClientDriver ctor: OpcUaClientDriver(OpcUaClientDriverOptions options, string driverInstanceId, ILogger<OpcUaClientDriver>? logger = null). Look at GalaxyDriverFactoryTests.cs / the Modbus factory tests for the test conventions (xUnit v3 + Shouldly).

Step 1: Write the failing test (OpcUaClientDriverFactoryTests.cs)

using Shouldly;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
using Xunit;

namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;

public class OpcUaClientDriverFactoryTests
{
    private const string SampleConfig =
        """{"EndpointUrl":"opc.tcp://host:4840","SecurityMode":"None","AutoAcceptCertificates":true}""";

    [Fact]
    public void CreateInstance_builds_an_OpcUaClient_driver_with_the_right_identity()
    {
        var driver = OpcUaClientDriverFactoryExtensions.CreateInstance("drv-1", SampleConfig, NullLoggerFactory.Instance);
        driver.DriverType.ShouldBe("OpcUaClient");
        driver.DriverInstanceId.ShouldBe("drv-1");
    }

    [Fact]
    public void DriverTypeName_is_OpcUaClient()
        => OpcUaClientDriverFactoryExtensions.DriverTypeName.ShouldBe("OpcUaClient");

    [Fact]
    public void CreateInstance_throws_on_null_json_deserialisation()
        => Should.Throw<System.InvalidOperationException>(
            () => OpcUaClientDriverFactoryExtensions.CreateInstance("drv-1", "null", NullLoggerFactory.Instance));
}

(Confirm OpcUaClientDriver exposes DriverType / DriverInstanceId properties — it must, per IDriver. Adapt the assertions to the real property names if they differ. The OpcUaClient.Tests project must reference the driver project + Microsoft.Extensions.Logging.Abstractions — both almost certainly already present; add if missing.)

Step 2: Run — expect FAIL (OpcUaClientDriverFactoryExtensions not defined). dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests --filter "FullyQualifiedName~OpcUaClientDriverFactory"

Step 3: Implement the factory (OpcUaClientDriverFactoryExtensions.cs)

using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;

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

/// <summary>
///     Registers the OPC UA Client driver with the <see cref="DriverFactoryRegistry"/>. The driver's
///     <c>DriverConfig</c> JSON deserialises directly into <see cref="OpcUaClientDriverOptions"/>
///     (the same shape <see cref="OpcUaClientDriverProbe"/> reads), so no separate DTO is needed.
/// </summary>
public static class OpcUaClientDriverFactoryExtensions
{
    /// <summary>Driver type name — matches <c>DriverInstance.DriverType</c> values.</summary>
    public const string DriverTypeName = "OpcUaClient";

    // Match OpcUaClientDriverProbe exactly so factory + probe parse a config identically.
    private static readonly JsonSerializerOptions JsonOptions = new()
    {
        PropertyNameCaseInsensitive = true,
        UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
    };

    /// <summary>Register the OpcUaClient factory with the driver registry.</summary>
    /// <param name="registry">The driver factory registry to register with.</param>
    /// <param name="loggerFactory">Optional logger factory used to create per-instance loggers.</param>
    public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
    {
        ArgumentNullException.ThrowIfNull(registry);
        registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
    }

    /// <summary>Public for the Server-side bootstrapper + test consumers.</summary>
    /// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
    /// <param name="driverConfigJson">The JSON configuration string for the driver.</param>
    /// <param name="loggerFactory">Optional logger factory for the per-instance logger.</param>
    /// <returns>A configured <see cref="OpcUaClientDriver"/>.</returns>
    public static OpcUaClientDriver CreateInstance(
        string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory = null)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
        ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);

        var options = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(driverConfigJson, JsonOptions)
            ?? throw new InvalidOperationException(
                $"OpcUaClient driver config for '{driverInstanceId}' deserialised to null");

        return new OpcUaClientDriver(options, driverInstanceId, loggerFactory?.CreateLogger<OpcUaClientDriver>());
    }
}

(Confirm the using for OpcUaClientDriverOptions — it's in …Driver.OpcUaClient.Contracts; the driver project already references it. Confirm DriverFactoryRegistry's namespace is ZB.MOM.WW.OtOpcUa.Core.Hosting — check the Modbus factory's usings.)

Step 4: Register it in the bootstrap — add to DriverFactoryBootstrap.Register (alongside the other lines):

Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory);

Step 5: Run tests + full build

dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests --filter "FullyQualifiedName~OpcUaClientDriverFactory"
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.Host

Expected: tests PASS; Host builds 0 errors.

Step 6: Commit (stage by path)

git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverFactoryExtensions.cs src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDriverFactoryTests.cs
git commit -m "fix(opcuaclient): register the OpcUaClient driver factory (was always stubbed)"

Task 2: Verify — build, test, agent-driven live /run (the payoff)

Classification: verification Estimated implement time: ~5 min + live Parallelizable with: none blockedBy: Task 1

Files: none

Step 1: Full solution build + the touched suites:

dotnet build ZB.MOM.WW.OtOpcUa.slnx
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests

Expect 0 errors; green.

Step 2: Live docker-dev /run (agent drives; dev login DISABLED). The MAIN-opcua-eq OpcUaClient driver + FastUInt1 tag ({"FullName":"ns=3;s=FastUInt1"}) are already authored + deployed in the dev rig; the opc-plc sim is up at opc.tcp://10.100.0.35:50000 (start it if down: ssh 10.100.0.35 'cd /opt/otopcua-opcuaclient && docker compose up -d').

  • Rebuild central on the branch: docker compose -f docker-dev/docker-compose.yml up -d --build migrator central-1 central-2 (no new migration; the rebuild ships the branch code).
  • Redeploy: curl -sS -X POST http://localhost:9200/api/deployments -H "X-Api-Key: docker-dev-deploy-key" -H "Content-Type: application/json" -d '{}'.
  • Confirm the fix: docker logs --since 3m otopcua-dev-central-1-1 2>&1 | grep -iE "OpcUaClient|opc-plc|no factory|session|subscrib" — must NOT show "no factory for driver type OpcUaClient"; should show the driver connecting/subscribing.
  • Confirm the live value (the router payoff): retry-read ns=2;s=EQ-55297329838d/FastUInt1 from opc.tcp://localhost:4840 via Client.CLI until it shows a Good, changing value (not BadWaitingForInitialData): dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n "ns=2;s=EQ-55297329838d/FastUInt1" (read twice ~2s apart — FastUInt1 increments, so the value should change).

Step 3: On green, finish via superpowers-extended-cc:finishing-a-development-branch (intent: merge-to-master + push).


Out of scope

Protocol-driver (Modbus/S7) equipment-tag↔tag-table linkage; Galaxy gateway; Phase B/C — separate milestones (see pending.md).