Files
lmxopcua/docs/plans/2026-06-13-opcuaclient-factory-design.md
T
Joseph Doherty 5aa1030be9 docs(opcuaclient): design — register the OpcUaClient driver factory (fixes always-stubbed bug)
Mirror ModbusDriverFactoryExtensions: NEW OpcUaClientDriverFactoryExtensions
(Register + CreateInstance deserialising OpcUaClientDriverOptions, like the
probe) + one line in DriverFactoryBootstrap.Register. Unblocks the first
end-to-end live equipment-tag value (live-proves the FullName→NodeId router).
2026-06-13 08:11:41 -04:00

5.5 KiB

OpcUaClient driver factory — Design

Date: 2026-06-13 Status: Approved — ready for implementation planning Scope: Register the OpcUaClient driver factory so OpcUaClient driver instances are actually constructed instead of stubbed. Fixes a real bug AND yields the first end-to-end live equipment-tag value (proving the live-value router shipped in c4435e4f).

Goal

Make DriverType="OpcUaClient" instances real: the runtime currently logs "no factory for driver type OpcUaClient … falling back to stub", so OpcUaClient drivers never connect, subscribe, or publish. After this, an OpcUaClient equipment tag (TagConfig.FullName = "<upstream NodeId>") subscribes to the upstream server and publishes values, which the (already-shipped) FullName→NodeId router delivers to the materialised variable.

Root cause (confirmed)

  • src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs:98-107 (Register) wires AbCip/AbLegacy/FOCAS/Galaxy/Modbus/S7/TwinCAT factories — but omits OpcUaClient.
  • The Driver.OpcUaClient project has no …FactoryExtensions class (only OpcUaClientDriver.cs + OpcUaClientDriverProbe.cs). So there is nothing to register.
  • Result: IDriverFactory.TryCreate("OpcUaClient", …) returns null → DriverInstanceActor falls back to a stub. (Note: this is the missing-factory stub path, distinct from ShouldStub, which only stubs windows-only "Galaxy"/"Historian.Wonderware". OpcUaClient is cross-platform, so once a factory exists it runs as a real driver — including in docker-dev Linux.)

Approach — mirror ModbusDriverFactoryExtensions

The OpcUaClient config JSON is the OpcUaClientDriverOptions shape — the probe already does JsonSerializer.Deserialize<OpcUaClientDriverOptions>(configJson, _opts) with _opts = { PropertyNameCaseInsensitive = true, UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip }. So no DTO is needed.

Components

  1. NEW src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverFactoryExtensions.cs — mirrors ModbusDriverFactoryExtensions:

    • const string DriverTypeName = "OpcUaClient";
    • private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip }; (same as the probe).
    • public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
    • public static OpcUaClientDriver CreateInstance(string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory = null): deserialize → throw if null → new OpcUaClientDriver(options, driverInstanceId, loggerFactory?.CreateLogger<OpcUaClientDriver>()).
    • Validate required: at minimum a usable endpoint (EndpointUrl or EndpointUrls) — but keep validation light; the driver's InitializeAsync already fails clearly on a bad endpoint, and a too-strict factory could reject valid configs. (Match Modbus's level: it only checks Host is present.) Decision: deserialize-and-construct; let InitializeAsync own connection validation. Optionally a null/empty-endpoint guard with a clear message.
  2. MODIFY DriverFactoryBootstrap.cs — add one line to Register: Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory); (The Host already references the Driver.OpcUaClient project and registers OpcUaClientDriverProbe.)

Error handling

  • Null/invalid config JSON → InvalidOperationException naming the instance (mirrors Modbus). Connection/endpoint problems surface through InitializeAsync (the driver's existing failover-sweep diagnostics), not the factory.

Testing (no bUnit)

  • Unit (xUnit + Shouldly): OpcUaClientDriverFactoryExtensions.CreateInstance with a representative config JSON (e.g. {"EndpointUrl":"opc.tcp://host:4840","SecurityMode":"None","AutoAcceptCertificates":true}) returns an OpcUaClientDriver whose DriverType == "OpcUaClient" and DriverInstanceId matches; null/garbage JSON throws. (Mirror the Modbus factory test; place in the OpcUaClient driver's test project, or Admin.Tests if that's where factory tests live — confirm at plan time.)
  • Live docker-dev /run (agent-driven; login disabled) — the payoff: the MAIN-opcua-eq OpcUaClient driver + FastUInt1 tag ({"FullName":"ns=3;s=FastUInt1"}) are already authored + deployed in the dev rig (left from the router live-verify); opc-plc sim is up at opc.tcp://10.100.0.35:50000. Rebuild central on the branch, redeploy, and confirm: (a) the logs no longer say "no factory for driver type OpcUaClient" — the driver connects to opc-plc + subscribes; (b) the variable ns=2;s=EQ-55297329838d/FastUInt1 on the OtOpcUa server (opc.tcp://localhost:4840) shows a live, changing value via Client.CLI read/subscribe (not BadWaitingForInitialData). This live-proves the router (c4435e4f) end-to-end with a real driver.

Out of scope

  • Protocol-driver (Modbus/S7) equipment-tag↔tag-table linkage (the larger gap — separate milestone).
  • Galaxy gateway availability.
  • Any change to the OpcUaClient driver's connection/subscribe logic (it already works; it just was never instantiated).

Hard rules

Stage by path; never git add .; never stage sql_login.txt / src/Server/.../Host/pki/ / pending.md / current.md. No force-push, no --no-verify. No Configuration/EF migration change. Build on a feature branch off master.