feat(mtconnect): driver factory delegating to the single config parser (Task 15)

The factory carries NO config DTO of its own: CreateInstance delegates to
MTConnectDriver.ParseOptions, the single authority for the config document.
A second DTO would drift from the parse the runtime's ReinitializeAsync path
uses, and the drift would only surface at deploy time.

Construction is connection-free (agentClientFactory: null) because the Wave-0
universal browser builds a throwaway instance per browse probe, and the
returned instance is never narrowed — its five capability interfaces ARE the
runtime's dispatch surface. Pinned by tests asserting all five plus the
deliberate absence of IWritable.

DriverTypeName is the literal "MTConnect"; Task 16 adds DriverTypeNames.MTConnect
and the host registration that must equal it.
This commit is contained in:
Joseph Doherty
2026-07-24 17:26:15 -04:00
parent 1f1ae174d9
commit 7a627d6c87
2 changed files with 465 additions and 0 deletions
@@ -0,0 +1,113 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// Static factory registration helper for <see cref="MTConnectDriver"/>. The Host registers it
/// once at startup; the bootstrapper then materialises MTConnect <c>DriverInstance</c> rows from
/// the deployed configuration into live driver instances. Mirrors
/// <c>ModbusDriverFactoryExtensions</c> / <c>FocasDriverFactoryExtensions</c>.
/// </summary>
/// <remarks>
/// <para>
/// <b>There is exactly one parser for the config document.</b> This factory does not carry a
/// config DTO of its own — it delegates to <see cref="MTConnectDriver.ParseOptions"/>, which
/// the driver itself must own because the runtime delivers a config <i>change</i> to a live
/// instance (<c>DriverInstanceActor</c> assigns its driver once and calls
/// <c>ReinitializeAsync(newJson)</c> thereafter). A second DTO here would drift from that
/// one silently, and the drift would first show up at deploy time as an operator's edit
/// being applied differently by the two paths — or not at all.
/// </para>
/// <para>
/// <b>Construction opens nothing.</b> The Wave-0 universal discovery browser builds a
/// throwaway instance through this factory purely to read
/// <c>ITagDiscovery.SupportsOnlineDiscovery</c>, and never initializes it — so a factory
/// that dialled the Agent (or that pre-built an <see cref="IMTConnectAgentClient"/>) would
/// open a connection per AdminUI browse render against a possibly-unreachable Agent. The
/// agent-client factory is therefore left null: <see cref="MTConnectDriver"/> builds the
/// production client inside <c>InitializeAsync</c>.
/// </para>
/// <para>
/// <b>The instance is returned as its concrete type.</b> The runtime resolves every optional
/// capability (<see cref="IReadable"/>, <see cref="ISubscribable"/>,
/// <see cref="ITagDiscovery"/>, <see cref="IHostConnectivityProbe"/>,
/// <see cref="IRediscoverable"/>) by pattern-matching the object
/// <see cref="DriverFactoryRegistry"/> hands back, so nothing here may narrow it — a
/// narrowed return is how a capability ends up implemented but never dispatched. There is
/// deliberately no <c>IWritable</c>: the MTConnect Agent surface is read-only.
/// </para>
/// </remarks>
public static class MTConnectDriverFactoryExtensions
{
/// <summary>
/// The <c>DriverInstance.DriverType</c> value this factory answers to.
/// </summary>
/// <remarks>
/// Kept identical to <see cref="MTConnectDriver.DriverType"/> — the registry key and the
/// instance's self-reported type must agree or the runtime would look the driver up under a
/// name it never registered. Task 16 adds the matching <c>DriverTypeNames.MTConnect</c>
/// constant (the repo's convention is that dispatch maps key off those constants) and wires
/// the Host registration + guard test; this literal is what that constant must equal.
/// </remarks>
public const string DriverTypeName = "MTConnect";
/// <summary>
/// Register the MTConnect factory with the driver registry. The optional
/// <paramref name="loggerFactory"/> is captured at registration time and used to construct
/// an <see cref="ILogger{TCategoryName}"/> per driver instance — without it the driver runs
/// with the null logger (tests and standalone callers stay unchanged).
/// </summary>
/// <param name="registry">The driver factory registry to register with.</param>
/// <param name="loggerFactory">Optional logger factory for creating loggers per driver instance.</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 driver's <c>DriverConfig</c> JSON.</param>
/// <returns>The constructed <see cref="MTConnectDriver"/> instance.</returns>
public static MTConnectDriver CreateInstance(string driverInstanceId, string driverConfigJson)
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);
/// <summary>Logger-aware overload — used by <see cref="Register"/>'s closure when wired through DI.</summary>
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
/// <param name="driverConfigJson">The driver's <c>DriverConfig</c> JSON.</param>
/// <param name="loggerFactory">Optional logger factory for creating loggers per driver instance.</param>
/// <returns>The constructed <see cref="MTConnectDriver"/> instance.</returns>
/// <exception cref="ArgumentException">
/// <paramref name="driverInstanceId"/> or <paramref name="driverConfigJson"/> is null or blank.
/// </exception>
/// <exception cref="InvalidOperationException">
/// The config document is unparseable, deserialises to null, omits the required
/// <c>agentUri</c>, or carries an unauthorable enum/tag value. Throwing is correct at this
/// seam: a deployment carrying such a row must fail rather than register a driver pointed at
/// nothing, and the discovery browser's <c>CanBrowse</c> catches factory exceptions so a
/// half-authored config merely disables the address picker.
/// </exception>
public static MTConnectDriver CreateInstance(
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
// The single config authority — see the class remarks for why this is not a second DTO.
// Note the asymmetry with the driver's own lifecycle path: an empty ("{}" / "[]") document
// there means "no config supplied, keep the constructor options", but this factory HAS no
// constructor options to keep, so an empty document is simply a config with no agentUri and
// must fault.
var options = MTConnectDriver.ParseOptions(driverInstanceId, driverConfigJson);
// Named arguments deliberately: the ctor's trailing parameters are all optional, so a
// future insertion could silently re-bind a positional argument to the wrong one.
return new MTConnectDriver(
options,
driverInstanceId,
agentClientFactory: null,
logger: loggerFactory?.CreateLogger<MTConnectDriver>());
}
}