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)); + } +}