diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs
new file mode 100644
index 0000000..7d4bdef
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs
@@ -0,0 +1,198 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using ZB.MOM.WW.OtOpcUa.Core.Hosting;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Ipc;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
+
+///
+/// Static factory registration helper for . Server's Program.cs
+/// calls once at startup; the bootstrapper (task #248) then
+/// materialises FOCAS DriverInstance rows from the central config DB into live driver
+/// instances. Mirrors GalaxyProxyDriverFactoryExtensions; no dependency on
+/// Microsoft.Extensions.DependencyInjection so the driver project stays DI-free.
+///
+///
+/// The DriverConfig JSON selects the backend:
+///
+/// - "Backend": "ipc" (default) — wires
+/// against a named-pipe talking to a separate
+/// Driver.FOCAS.Host process (Tier-C isolation). Requires PipeName +
+/// SharedSecret.
+/// - "Backend": "fwlib" — direct in-process Fwlib32.dll P/Invoke via
+/// . Use only when the main server is licensed
+/// for FOCAS and you accept the native-crash blast-radius trade-off.
+/// - "Backend": "unimplemented" — returns the no-op factory; useful for
+/// scaffolding DriverInstance rows before the Host is deployed so the server boots.
+///
+/// Devices / Tags / Probe / Timeout / Series come from the same JSON and feed directly
+/// into .
+///
+public static class FocasDriverFactoryExtensions
+{
+ public const string DriverTypeName = "FOCAS";
+
+ ///
+ /// Register the FOCAS driver factory in the supplied .
+ /// Throws if 'FOCAS' is already registered — single-instance per process.
+ ///
+ public static void Register(DriverFactoryRegistry registry)
+ {
+ ArgumentNullException.ThrowIfNull(registry);
+ registry.Register(DriverTypeName, CreateInstance);
+ }
+
+ internal static FocasDriver CreateInstance(string driverInstanceId, string driverConfigJson)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
+ ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
+
+ var dto = JsonSerializer.Deserialize(driverConfigJson, JsonOptions)
+ ?? throw new InvalidOperationException(
+ $"FOCAS driver config for '{driverInstanceId}' deserialised to null");
+
+ // Eager-validate top-level Series so a typo fails fast regardless of whether Devices
+ // are populated yet (common during rollout when rows are seeded before CNCs arrive).
+ _ = ParseSeries(dto.Series);
+
+ var options = new FocasDriverOptions
+ {
+ Devices = dto.Devices is { Count: > 0 }
+ ? [.. dto.Devices.Select(d => new FocasDeviceOptions(
+ HostAddress: d.HostAddress ?? throw new InvalidOperationException(
+ $"FOCAS config for '{driverInstanceId}' has a device missing HostAddress"),
+ DeviceName: d.DeviceName,
+ Series: ParseSeries(d.Series ?? dto.Series)))]
+ : [],
+ Tags = dto.Tags is { Count: > 0 }
+ ? [.. dto.Tags.Select(t => new FocasTagDefinition(
+ Name: t.Name ?? throw new InvalidOperationException(
+ $"FOCAS config for '{driverInstanceId}' has a tag missing Name"),
+ DeviceHostAddress: t.DeviceHostAddress ?? throw new InvalidOperationException(
+ $"FOCAS tag '{t.Name}' in '{driverInstanceId}' missing DeviceHostAddress"),
+ Address: t.Address ?? throw new InvalidOperationException(
+ $"FOCAS tag '{t.Name}' in '{driverInstanceId}' missing Address"),
+ DataType: ParseDataType(t.DataType, t.Name!, driverInstanceId),
+ Writable: t.Writable ?? true,
+ WriteIdempotent: t.WriteIdempotent ?? false))]
+ : [],
+ Probe = new FocasProbeOptions
+ {
+ Enabled = dto.Probe?.Enabled ?? true,
+ Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_000),
+ Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000),
+ },
+ Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 2_000),
+ };
+
+ var clientFactory = BuildClientFactory(dto, driverInstanceId);
+ return new FocasDriver(options, driverInstanceId, clientFactory);
+ }
+
+ internal static IFocasClientFactory BuildClientFactory(
+ FocasDriverConfigDto dto, string driverInstanceId)
+ {
+ var backend = (dto.Backend ?? "ipc").Trim().ToLowerInvariant();
+ return backend switch
+ {
+ "ipc" => BuildIpcFactory(dto, driverInstanceId),
+ "fwlib" or "fwlib32" => new FwlibFocasClientFactory(),
+ "unimplemented" or "none" or "stub" => new UnimplementedFocasClientFactory(),
+ _ => throw new InvalidOperationException(
+ $"FOCAS driver config for '{driverInstanceId}' has unknown Backend '{dto.Backend}'. " +
+ "Expected one of: ipc, fwlib, unimplemented."),
+ };
+ }
+
+ private static IpcFocasClientFactory BuildIpcFactory(
+ FocasDriverConfigDto dto, string driverInstanceId)
+ {
+ var pipeName = dto.PipeName
+ ?? throw new InvalidOperationException(
+ $"FOCAS driver config for '{driverInstanceId}' missing required PipeName (Tier-C ipc backend)");
+ var sharedSecret = dto.SharedSecret
+ ?? throw new InvalidOperationException(
+ $"FOCAS driver config for '{driverInstanceId}' missing required SharedSecret (Tier-C ipc backend)");
+ var connectTimeout = TimeSpan.FromMilliseconds(dto.ConnectTimeoutMs ?? 10_000);
+ var series = ParseSeries(dto.Series);
+
+ // Each IFocasClientFactory.Create() call opens a fresh pipe to the Host — matches the
+ // driver's one-client-per-device invariant. FocasIpcClient.ConnectAsync is awaited
+ // synchronously via GetAwaiter().GetResult() because IFocasClientFactory.Create is a
+ // sync contract; the blocking call lands inside FocasDriver.EnsureConnectedAsync,
+ // which immediately awaits IFocasClient.ConnectAsync afterwards so the perceived
+ // latency is identical to a fully-async factory.
+ return new IpcFocasClientFactory(
+ ipcClientFactory: () => FocasIpcClient.ConnectAsync(
+ pipeName: pipeName,
+ sharedSecret: sharedSecret,
+ connectTimeout: connectTimeout,
+ ct: CancellationToken.None).GetAwaiter().GetResult(),
+ series: series);
+ }
+
+ private static FocasCncSeries ParseSeries(string? raw)
+ {
+ if (string.IsNullOrWhiteSpace(raw)) return FocasCncSeries.Unknown;
+ return Enum.TryParse(raw, ignoreCase: true, out var s)
+ ? s
+ : throw new InvalidOperationException(
+ $"FOCAS Series '{raw}' is not one of {string.Join(", ", Enum.GetNames())}");
+ }
+
+ private static FocasDataType ParseDataType(string? raw, string tagName, string driverInstanceId)
+ {
+ if (string.IsNullOrWhiteSpace(raw))
+ throw new InvalidOperationException(
+ $"FOCAS tag '{tagName}' in '{driverInstanceId}' missing DataType");
+ return Enum.TryParse(raw, ignoreCase: true, out var dt)
+ ? dt
+ : throw new InvalidOperationException(
+ $"FOCAS tag '{tagName}' has unknown DataType '{raw}'. " +
+ $"Expected one of {string.Join(", ", Enum.GetNames())}");
+ }
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ ReadCommentHandling = JsonCommentHandling.Skip,
+ AllowTrailingCommas = true,
+ };
+
+ internal sealed class FocasDriverConfigDto
+ {
+ public string? Backend { get; init; }
+ public string? PipeName { get; init; }
+ public string? SharedSecret { get; init; }
+ public int? ConnectTimeoutMs { get; init; }
+ public string? Series { get; init; }
+ public int? TimeoutMs { get; init; }
+ public List? Devices { get; init; }
+ public List? Tags { get; init; }
+ public FocasProbeDto? Probe { get; init; }
+ }
+
+ internal sealed class FocasDeviceDto
+ {
+ public string? HostAddress { get; init; }
+ public string? DeviceName { get; init; }
+ public string? Series { get; init; }
+ }
+
+ internal sealed class FocasTagDto
+ {
+ public string? Name { get; init; }
+ public string? DeviceHostAddress { get; init; }
+ public string? Address { get; init; }
+ public string? DataType { get; init; }
+ public bool? Writable { get; init; }
+ public bool? WriteIdempotent { get; init; }
+ }
+
+ internal sealed class FocasProbeDto
+ {
+ public bool? Enabled { get; init; }
+ public int? IntervalMs { get; init; }
+ public int? TimeoutMs { get; init; }
+ }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj
index a17c4d7..b63b6c0 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj
@@ -14,6 +14,7 @@
+
diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs
index 956f317..0d62a8c 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs
@@ -9,6 +9,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy;
using ZB.MOM.WW.OtOpcUa.Server;
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
@@ -98,6 +99,7 @@ builder.Services.AddSingleton(_ =>
{
var registry = new DriverFactoryRegistry();
GalaxyProxyDriverFactoryExtensions.Register(registry);
+ FocasDriverFactoryExtensions.Register(registry);
return registry;
});
builder.Services.AddSingleton();
diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj b/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj
index c8aa856..4879b83 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj
+++ b/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj
@@ -35,6 +35,7 @@
+
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDriverFactoryExtensionsTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDriverFactoryExtensionsTests.cs
new file mode 100644
index 0000000..2a20e9c
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDriverFactoryExtensionsTests.cs
@@ -0,0 +1,162 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Hosting;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
+
+///
+/// Task #220 — covers the DriverConfig JSON contract that
+/// parses when the bootstrap
+/// pipeline (task #248) materialises FOCAS DriverInstance rows. Pure unit tests, no pipe
+/// or CNC required.
+///
+[Trait("Category", "Unit")]
+public sealed class FocasDriverFactoryExtensionsTests
+{
+ [Fact]
+ public void Register_adds_FOCAS_entry_to_registry()
+ {
+ var registry = new DriverFactoryRegistry();
+ FocasDriverFactoryExtensions.Register(registry);
+ registry.TryGet("FOCAS").ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void Register_is_case_insensitive_via_registry()
+ {
+ var registry = new DriverFactoryRegistry();
+ FocasDriverFactoryExtensions.Register(registry);
+ registry.TryGet("focas").ShouldNotBeNull();
+ registry.TryGet("Focas").ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void CreateInstance_with_ipc_backend_and_valid_config_returns_FocasDriver()
+ {
+ const string json = """
+ {
+ "Backend": "ipc",
+ "PipeName": "OtOpcUaFocasHost",
+ "SharedSecret": "secret-for-test",
+ "ConnectTimeoutMs": 5000,
+ "Series": "Thirty_i",
+ "TimeoutMs": 3000,
+ "Devices": [
+ { "HostAddress": "focas://10.0.0.5:8193", "DeviceName": "Lathe1" }
+ ],
+ "Tags": [
+ { "Name": "Override", "DeviceHostAddress": "focas://10.0.0.5:8193",
+ "Address": "R100", "DataType": "Int32", "Writable": true }
+ ]
+ }
+ """;
+
+ var driver = FocasDriverFactoryExtensions.CreateInstance("focas-0", json);
+
+ driver.ShouldNotBeNull();
+ driver.DriverInstanceId.ShouldBe("focas-0");
+ driver.DriverType.ShouldBe("FOCAS");
+ }
+
+ [Fact]
+ public void CreateInstance_defaults_Backend_to_ipc_when_unspecified()
+ {
+ // No "Backend" key → defaults to ipc → requires PipeName + SharedSecret.
+ const string json = """
+ { "PipeName": "p", "SharedSecret": "s" }
+ """;
+ var driver = FocasDriverFactoryExtensions.CreateInstance("focas-default", json);
+ driver.DriverType.ShouldBe("FOCAS");
+ }
+
+ [Fact]
+ public void CreateInstance_ipc_backend_missing_PipeName_throws()
+ {
+ const string json = """{ "Backend": "ipc", "SharedSecret": "s" }""";
+ Should.Throw(
+ () => FocasDriverFactoryExtensions.CreateInstance("focas-missing-pipe", json))
+ .Message.ShouldContain("PipeName");
+ }
+
+ [Fact]
+ public void CreateInstance_ipc_backend_missing_SharedSecret_throws()
+ {
+ const string json = """{ "Backend": "ipc", "PipeName": "p" }""";
+ Should.Throw(
+ () => FocasDriverFactoryExtensions.CreateInstance("focas-missing-secret", json))
+ .Message.ShouldContain("SharedSecret");
+ }
+
+ [Fact]
+ public void CreateInstance_fwlib_backend_does_not_require_pipe_fields()
+ {
+ // Direct in-process Fwlib32 path. No pipe config needed; driver connects the DLL
+ // natively on first use.
+ const string json = """{ "Backend": "fwlib" }""";
+ var driver = FocasDriverFactoryExtensions.CreateInstance("focas-fwlib", json);
+ driver.DriverInstanceId.ShouldBe("focas-fwlib");
+ }
+
+ [Fact]
+ public void CreateInstance_unimplemented_backend_yields_driver_that_fails_fast_on_use()
+ {
+ // Useful for staging DriverInstance rows in the config DB before the Host is
+ // actually deployed — the server boots but reads/writes surface clear errors.
+ const string json = """{ "Backend": "unimplemented" }""";
+ var driver = FocasDriverFactoryExtensions.CreateInstance("focas-unimpl", json);
+ driver.DriverInstanceId.ShouldBe("focas-unimpl");
+ }
+
+ [Fact]
+ public void CreateInstance_unknown_backend_throws_with_expected_list()
+ {
+ const string json = """{ "Backend": "gibberish", "PipeName": "p", "SharedSecret": "s" }""";
+ Should.Throw(
+ () => FocasDriverFactoryExtensions.CreateInstance("focas-bad-backend", json))
+ .Message.ShouldContain("gibberish");
+ }
+
+ [Fact]
+ public void CreateInstance_rejects_unknown_Series()
+ {
+ const string json = """
+ { "Backend": "fwlib", "Series": "NotARealSeries" }
+ """;
+ Should.Throw(
+ () => FocasDriverFactoryExtensions.CreateInstance("focas-bad-series", json))
+ .Message.ShouldContain("NotARealSeries");
+ }
+
+ [Fact]
+ public void CreateInstance_rejects_tag_with_missing_DataType()
+ {
+ const string json = """
+ {
+ "Backend": "fwlib",
+ "Devices": [{ "HostAddress": "focas://1.1.1.1:8193" }],
+ "Tags": [{ "Name": "Broken", "DeviceHostAddress": "focas://1.1.1.1:8193", "Address": "R1" }]
+ }
+ """;
+ Should.Throw(
+ () => FocasDriverFactoryExtensions.CreateInstance("focas-bad-tag", json))
+ .Message.ShouldContain("DataType");
+ }
+
+ [Fact]
+ public void CreateInstance_null_or_whitespace_args_rejected()
+ {
+ Should.Throw(
+ () => FocasDriverFactoryExtensions.CreateInstance("", "{}"));
+ Should.Throw(
+ () => FocasDriverFactoryExtensions.CreateInstance("id", ""));
+ }
+
+ [Fact]
+ public void Register_twice_throws()
+ {
+ var registry = new DriverFactoryRegistry();
+ FocasDriverFactoryExtensions.Register(registry);
+ Should.Throw(
+ () => FocasDriverFactoryExtensions.Register(registry));
+ }
+}