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