diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverFactoryExtensions.cs
new file mode 100644
index 00000000..34797fa7
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverFactoryExtensions.cs
@@ -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;
+
+///
+/// Static factory registration helper for . The Host registers it
+/// once at startup; the bootstrapper then materialises MTConnect DriverInstance rows from
+/// the deployed configuration into live driver instances. Mirrors
+/// ModbusDriverFactoryExtensions / FocasDriverFactoryExtensions.
+///
+///
+///
+/// There is exactly one parser for the config document. This factory does not carry a
+/// config DTO of its own — it delegates to , which
+/// the driver itself must own because the runtime delivers a config change to a live
+/// instance (DriverInstanceActor assigns its driver once and calls
+/// ReinitializeAsync(newJson) 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.
+///
+///
+/// Construction opens nothing. The Wave-0 universal discovery browser builds a
+/// throwaway instance through this factory purely to read
+/// ITagDiscovery.SupportsOnlineDiscovery, and never initializes it — so a factory
+/// that dialled the Agent (or that pre-built an ) would
+/// open a connection per AdminUI browse render against a possibly-unreachable Agent. The
+/// agent-client factory is therefore left null: builds the
+/// production client inside InitializeAsync.
+///
+///
+/// The instance is returned as its concrete type. The runtime resolves every optional
+/// capability (, ,
+/// , ,
+/// ) by pattern-matching the object
+/// 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 IWritable: the MTConnect Agent surface is read-only.
+///
+///
+public static class MTConnectDriverFactoryExtensions
+{
+ ///
+ /// The DriverInstance.DriverType value this factory answers to.
+ ///
+ ///
+ /// Kept identical to — 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 DriverTypeNames.MTConnect
+ /// 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.
+ ///
+ public const string DriverTypeName = "MTConnect";
+
+ ///
+ /// Register the MTConnect factory with the driver registry. The optional
+ /// is captured at registration time and used to construct
+ /// an per driver instance — without it the driver runs
+ /// with the null logger (tests and standalone callers stay unchanged).
+ ///
+ /// The driver factory registry to register with.
+ /// Optional logger factory for creating loggers per driver instance.
+ public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
+ {
+ ArgumentNullException.ThrowIfNull(registry);
+ registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
+ }
+
+ /// Public for the Server-side bootstrapper + test consumers.
+ /// The unique identifier for the driver instance.
+ /// The driver's DriverConfig JSON.
+ /// The constructed instance.
+ public static MTConnectDriver CreateInstance(string driverInstanceId, string driverConfigJson)
+ => CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);
+
+ /// Logger-aware overload — used by 's closure when wired through DI.
+ /// The unique identifier for the driver instance.
+ /// The driver's DriverConfig JSON.
+ /// Optional logger factory for creating loggers per driver instance.
+ /// The constructed instance.
+ ///
+ /// or is null or blank.
+ ///
+ ///
+ /// The config document is unparseable, deserialises to null, omits the required
+ /// agentUri, 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 CanBrowse catches factory exceptions so a
+ /// half-authored config merely disables the address picker.
+ ///
+ 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());
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectFactoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectFactoryTests.cs
new file mode 100644
index 00000000..49dea03c
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectFactoryTests.cs
@@ -0,0 +1,352 @@
+using System.Diagnostics;
+using System.Net;
+using System.Net.Sockets;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.Hosting;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
+
+///
+/// Task 15 — : the seam that turns a
+/// DriverInstance row's DriverConfig JSON into a live .
+///
+/// Three properties carry the weight here. (1) One parser. The factory delegates to
+/// MTConnectDriver.ParseOptions rather than deserializing a second DTO, so the config
+/// document has a single authority and the runtime's ReinitializeAsync path cannot
+/// drift from the bootstrap path. (2) Connection-free construction. The Wave-0
+/// universal browser builds a throwaway instance purely to read
+/// SupportsOnlineDiscovery, so a factory that dialled would open a socket per AdminUI
+/// browse render against a possibly-unreachable Agent. (3) No dropped capability. The
+/// registry hands the runtime an ; every capability is resolved by
+/// pattern-matching the concrete instance, so the interface set on the object the factory
+/// returns IS the dispatch surface.
+///
+///
+public sealed class MTConnectFactoryTests
+{
+ private const uint Good = 0x00000000u;
+ private const uint BadWaitingForInitialData = 0x80320000u;
+ private const uint BadNodeIdUnknown = 0x80340000u;
+
+ // ----------------------------------------------------------------- construction
+
+ /// The plan's headline case: a one-key document is a complete MTConnect config.
+ [Fact]
+ public void Factory_builds_a_driver_from_minimal_config()
+ {
+ var d = MTConnectDriverFactoryExtensions.CreateInstance("mt1", "{\"agentUri\":\"http://a:5000\"}");
+
+ d.DriverType.ShouldBe("MTConnect");
+ d.DriverInstanceId.ShouldBe("mt1");
+ }
+
+ ///
+ /// The factory's own type name and the instance's must be
+ /// the same string, or a registered factory would materialise a driver the runtime looks up
+ /// under a different key.
+ ///
+ [Fact]
+ public void DriverTypeName_matches_the_instances_DriverType()
+ {
+ var d = MTConnectDriverFactoryExtensions.CreateInstance("mt1", "{\"agentUri\":\"http://a:5000\"}");
+
+ MTConnectDriverFactoryExtensions.DriverTypeName.ShouldBe("MTConnect");
+ d.DriverType.ShouldBe(MTConnectDriverFactoryExtensions.DriverTypeName);
+ }
+
+ // ----------------------------------------------------------------- rejection
+
+ ///
+ /// No agentUri means there is no Agent to talk to. Throwing is correct: the browser's
+ /// CanBrowse catches factory exceptions (a half-authored config just disables the
+ /// picker), and a deployment carrying such a row must fail rather than register a driver
+ /// pointed at nothing.
+ ///
+ [Fact]
+ public void Factory_rejects_config_without_agentUri()
+ => Should.Throw(() => MTConnectDriverFactoryExtensions.CreateInstance("mt1", "{}"));
+
+ ///
+ /// A whitespace-only agentUri is the same absence spelled differently — it must not
+ /// slip past the required check and become a driver dialling an empty URI.
+ ///
+ [Fact]
+ public void Factory_rejects_a_blank_agentUri()
+ => Should.Throw(
+ () => MTConnectDriverFactoryExtensions.CreateInstance("mt1", "{\"agentUri\":\" \"}"));
+
+ ///
+ /// Unparseable JSON faults loudly rather than silently yielding a defaulted driver — the
+ /// "empty bytes are not an empty configuration" rule (#485) applied at the factory seam.
+ ///
+ [Fact]
+ public void Factory_rejects_unparseable_json_rather_than_defaulting()
+ {
+ var ex = Should.Throw(
+ () => MTConnectDriverFactoryExtensions.CreateInstance("mt1", "{ this is not json"));
+
+ ex.Message.ShouldContain("mt1");
+ }
+
+ /// A JSON null document is not a config either.
+ [Fact]
+ public void Factory_rejects_a_null_json_document()
+ => Should.Throw(() => MTConnectDriverFactoryExtensions.CreateInstance("mt1", "null"));
+
+ /// An empty config string has nothing to build from; the id likewise.
+ [Theory]
+ [InlineData("mt1", "")]
+ [InlineData("mt1", " ")]
+ [InlineData("", "{\"agentUri\":\"http://a:5000\"}")]
+ public void Factory_rejects_empty_arguments(string id, string json)
+ => Should.Throw(() => MTConnectDriverFactoryExtensions.CreateInstance(id, json));
+
+ // ----------------------------------------------------------------- connection-free
+
+ ///
+ /// The factory opens no socket. Asserted directly: a real listener is bound, the
+ /// driver is built against its address, and nothing ever arrives on the accept queue. A
+ /// regression here would make every AdminUI browse render dial the Agent.
+ ///
+ [Fact]
+ public async Task CreateInstance_opens_no_socket_to_the_configured_agent()
+ {
+ var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ try
+ {
+ var port = ((IPEndPoint)listener.LocalEndpoint).Port;
+
+ var sw = Stopwatch.StartNew();
+ var driver = MTConnectDriverFactoryExtensions.CreateInstance(
+ "mt1", $"{{\"agentUri\":\"http://127.0.0.1:{port}\"}}");
+ sw.Stop();
+
+ driver.ShouldNotBeNull();
+
+ // Generous grace for a connect that a background thread might land late.
+ await Task.Delay(250, TestContext.Current.CancellationToken);
+
+ listener.Pending().ShouldBeFalse(
+ "MTConnectDriverFactoryExtensions.CreateInstance dialled the Agent — the browser " +
+ "builds a throwaway instance per browse probe, so construction must open nothing.");
+ sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(2));
+ }
+ finally
+ {
+ listener.Stop();
+ listener.Dispose();
+ }
+ }
+
+ ///
+ /// The same property against an address that can never answer (TEST-NET-1, RFC 5737):
+ /// construction returns promptly instead of blocking on a connect that will only end in a
+ /// timeout.
+ ///
+ [Fact]
+ public void CreateInstance_returns_immediately_for_a_black_hole_agent()
+ {
+ var sw = Stopwatch.StartNew();
+ var driver = MTConnectDriverFactoryExtensions.CreateInstance(
+ "mt1", "{\"agentUri\":\"http://192.0.2.1:5000\"}");
+ sw.Stop();
+
+ driver.DriverInstanceId.ShouldBe("mt1");
+ sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(2));
+ }
+
+ // ----------------------------------------------------------------- registry wiring
+
+ ///
+ /// puts the type in the registry
+ /// under the same key the bootstrapper looks up, and the registered delegate — not just the
+ /// static helper — builds a working instance.
+ ///
+ [Fact]
+ public void Register_adds_the_type_and_the_registered_delegate_builds_a_driver()
+ {
+ var registry = new DriverFactoryRegistry();
+
+ MTConnectDriverFactoryExtensions.Register(registry);
+
+ registry.RegisteredTypes.ShouldContain("MTConnect");
+
+ var factory = registry.TryGet("MTConnect");
+ factory.ShouldNotBeNull();
+
+ var driver = factory("mt-from-registry", "{\"agentUri\":\"http://a:5000\"}");
+
+ driver.DriverInstanceId.ShouldBe("mt-from-registry");
+ driver.DriverType.ShouldBe("MTConnect");
+ }
+
+ ///
+ /// The anti-dormancy pin. The runtime resolves every optional capability by
+ /// pattern-matching the instance the registry hands back, so a capability the driver
+ /// implements but the factory's return path hides would be implemented-yet-never-dispatched
+ /// — a failure shape this repo has shipped twice. Asserted on the
+ /// the registry delegate returns, which is exactly what the bootstrapper holds.
+ ///
+ [Fact]
+ public void Registered_delegate_returns_an_instance_carrying_every_capability()
+ {
+ var registry = new DriverFactoryRegistry();
+ MTConnectDriverFactoryExtensions.Register(registry);
+
+ IDriver driver = registry.TryGet("MTConnect")!("mt1", "{\"agentUri\":\"http://a:5000\"}");
+
+ driver.ShouldBeOfType();
+ (driver is IReadable).ShouldBeTrue("MTConnect must dispatch reads");
+ (driver is ISubscribable).ShouldBeTrue("MTConnect must dispatch subscriptions");
+ (driver is ITagDiscovery).ShouldBeTrue("MTConnect must dispatch browse/discovery");
+ (driver is IHostConnectivityProbe).ShouldBeTrue("MTConnect must dispatch host connectivity");
+ (driver is IRediscoverable).ShouldBeTrue("MTConnect must dispatch rediscovery");
+ }
+
+ ///
+ /// The other half of the capability pin: MTConnect's Agent surface is read-only, so the
+ /// instance must NOT be . If it ever became writable, the write gate
+ /// would start offering an operation the protocol cannot honour.
+ ///
+ [Fact]
+ public void Registered_delegate_returns_an_instance_that_is_not_writable()
+ {
+ var registry = new DriverFactoryRegistry();
+ MTConnectDriverFactoryExtensions.Register(registry);
+
+ IDriver driver = registry.TryGet("MTConnect")!("mt1", "{\"agentUri\":\"http://a:5000\"}");
+
+ (driver is IWritable).ShouldBeFalse("the MTConnect Agent surface is read-only");
+ }
+
+ /// Registering into a null registry is a wiring bug, not a silent no-op.
+ [Fact]
+ public void Register_rejects_a_null_registry()
+ => Should.Throw(() => MTConnectDriverFactoryExtensions.Register(null!));
+
+ // ----------------------------------------------------------------- config round-trip
+
+ ///
+ /// An authored tags array reaches options.Tags. Observed through the
+ /// observation index the constructor builds from those options: an authored id answers
+ /// BadWaitingForInitialData ("subscribed, no value yet"), an unauthored one answers
+ /// BadNodeIdUnknown. Status codes are literals so a wrong constant cannot be masked
+ /// by both sides sharing one symbol.
+ ///
+ [Fact]
+ public void Authored_tags_round_trip_into_the_driver_options()
+ {
+ const string json = """
+ {
+ "agentUri": "http://a:5000",
+ "tags": [
+ { "fullName": "dev1_pos", "driverDataType": "Float64" },
+ { "fullName": "dev1_execution", "driverDataType": "String" }
+ ]
+ }
+ """;
+
+ var driver = MTConnectDriverFactoryExtensions.CreateInstance("mt1", json);
+
+ driver.ObservationIndex.Get("dev1_pos").StatusCode.ShouldBe(BadWaitingForInitialData);
+ driver.ObservationIndex.Get("dev1_execution").StatusCode.ShouldBe(BadWaitingForInitialData);
+ driver.ObservationIndex.Get("dev1_never_authored").StatusCode.ShouldBe(BadNodeIdUnknown);
+ }
+
+ ///
+ /// An enum authored as a name parses to that member — proved by behaviour, not by
+ /// reading the option back: a Float64 tag coerces the Agent's text into a real
+ /// , whereas the enum's member 0 (Boolean) or a defaulted
+ /// String would not.
+ ///
+ [Fact]
+ public void Enum_fields_authored_as_names_parse_to_that_member()
+ {
+ var driver = MTConnectDriverFactoryExtensions.CreateInstance(
+ "mt1",
+ """
+ { "agentUri": "http://a:5000",
+ "tags": [ { "fullName": "dev1_pos", "driverDataType": "Float64" } ] }
+ """);
+
+ driver.ObservationIndex.Apply(new MTConnectStreamsResult(
+ InstanceId: 1L,
+ NextSequence: 2L,
+ FirstSequence: 1L,
+ Observations: [new MTConnectObservation(
+ "dev1_pos", "12.5", new DateTime(2026, 7, 24, 12, 0, 0, DateTimeKind.Utc), false)]));
+
+ var snap = driver.ObservationIndex.Get("dev1_pos");
+
+ snap.StatusCode.ShouldBe(Good);
+ snap.Value.ShouldBeOfType().ShouldBe(12.5d);
+ }
+
+ ///
+ /// The enum-serialization trap. The factory deliberately carries no
+ /// JsonStringEnumConverter: a numerically-serialized enum — the shape an AdminUI page
+ /// that serialized the enum by value would emit — must fault at deploy rather than silently
+ /// landing on member 0 (Boolean), which would mis-coerce every value of that tag.
+ ///
+ [Fact]
+ public void Numerically_serialized_enum_faults_rather_than_landing_on_member_zero()
+ => Should.Throw(() => MTConnectDriverFactoryExtensions.CreateInstance(
+ "mt1",
+ """
+ { "agentUri": "http://a:5000",
+ "tags": [ { "fullName": "dev1_pos", "driverDataType": 8 } ] }
+ """));
+
+ /// An enum name that names no member is an authoring error, reported with the field.
+ [Fact]
+ public void Unknown_enum_name_faults_naming_the_offending_tag()
+ {
+ var ex = Should.Throw(() => MTConnectDriverFactoryExtensions.CreateInstance(
+ "mt1",
+ """
+ { "agentUri": "http://a:5000",
+ "tags": [ { "fullName": "dev1_pos", "driverDataType": "Flot64" } ] }
+ """));
+
+ ex.Message.ShouldContain("dev1_pos");
+ }
+
+ ///
+ /// Scalar knobs reach the options too — the factory must not quietly drop a section it does
+ /// not itself read. Proved through the one seam a non-initialized driver exposes: an
+ /// authored reconnect block changes the backoff the driver would apply.
+ ///
+ [Fact]
+ public void Reconnect_and_probe_sections_round_trip_into_the_driver_options()
+ {
+ const string json = """
+ {
+ "agentUri": "http://a:5000",
+ "deviceName": "dev1",
+ "requestTimeoutMs": 1234,
+ "reconnect": { "minBackoffMs": 500, "maxBackoffMs": 4000, "backoffMultiplier": 3.0 },
+ "probe": { "enabled": false, "intervalMs": 7000, "timeoutMs": 900 }
+ }
+ """;
+
+ var options = MTConnectDriver.ParseOptions("mt1", json);
+
+ options.AgentUri.ShouldBe("http://a:5000");
+ options.DeviceName.ShouldBe("dev1");
+ options.RequestTimeoutMs.ShouldBe(1234);
+ options.Reconnect.MinBackoffMs.ShouldBe(500);
+ options.Reconnect.MaxBackoffMs.ShouldBe(4000);
+ options.Reconnect.BackoffMultiplier.ShouldBe(3.0);
+ options.Probe.Enabled.ShouldBeFalse();
+ options.Probe.Interval.ShouldBe(TimeSpan.FromMilliseconds(7000));
+ options.Probe.Timeout.ShouldBe(TimeSpan.FromMilliseconds(900));
+
+ // …and the factory is built on exactly that parse, not a second DTO: the same document
+ // through the factory must produce an instance, and the same rejection rules (asserted
+ // above) hold for both entry points.
+ MTConnectDriverFactoryExtensions.CreateInstance("mt1", json).DriverInstanceId.ShouldBe("mt1");
+ }
+}