using System.Text.Json; using System.Text.Json.Serialization; using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; /// /// Static factory registration helper for . Server's /// Program.cs calls once at startup; the bootstrapper /// then materialises FOCAS DriverInstance rows from the central config DB /// into live driver instances. /// /// /// The DriverConfig JSON selects the backend: /// /// "Backend": "wire" (default) — pure-managed FOCAS2 wire /// client () speaking directly to /// the CNC on TCP:8193. /// "Backend": "unimplemented" / "none" / "stub" /// — returns the no-op factory; useful for scaffolding DriverInstance /// rows before the CNC endpoint is reachable. /// /// 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), FixedTree = BuildFixedTree(dto.FixedTree), AlarmProjection = BuildAlarmProjection(dto.AlarmProjection), HandleRecycle = BuildHandleRecycle(dto.HandleRecycle), }; var clientFactory = BuildClientFactory(dto, driverInstanceId); return new FocasDriver(options, driverInstanceId, clientFactory); } internal static IFocasClientFactory BuildClientFactory( FocasDriverConfigDto dto, string driverInstanceId) { var backend = (dto.Backend ?? "wire").Trim().ToLowerInvariant(); return backend switch { "wire" => new WireFocasClientFactory(), "unimplemented" or "none" or "stub" => new UnimplementedFocasClientFactory(), _ => throw new InvalidOperationException( $"FOCAS driver config for '{driverInstanceId}' has unknown Backend '{dto.Backend}'. " + "Expected one of: wire, unimplemented. " + "(The legacy 'ipc' / 'fwlib' backends were retired in the Wire migration — " + "see docs/drivers/FOCAS.md.)"), }; } /// /// Map the optional FixedTree config section onto . /// A missing section keeps the hard-coded defaults (tree disabled); a present section /// with omitted intervals keeps each interval's default. Interval fields are JSON /// strings (e.g. "00:00:00.250") per docs/drivers/FOCAS.md. /// private static FocasFixedTreeOptions BuildFixedTree(FocasFixedTreeDto? dto) { if (dto is null) return new FocasFixedTreeOptions(); var defaults = new FocasFixedTreeOptions(); return new FocasFixedTreeOptions { Enabled = dto.Enabled ?? defaults.Enabled, PollInterval = dto.PollInterval ?? defaults.PollInterval, ProgramPollInterval = dto.ProgramPollInterval ?? defaults.ProgramPollInterval, TimerPollInterval = dto.TimerPollInterval ?? defaults.TimerPollInterval, }; } /// /// Map the optional AlarmProjection config section onto /// . Missing section keeps the disabled default. /// private static FocasAlarmProjectionOptions BuildAlarmProjection(FocasAlarmProjectionDto? dto) { if (dto is null) return new FocasAlarmProjectionOptions(); var defaults = new FocasAlarmProjectionOptions(); return new FocasAlarmProjectionOptions { Enabled = dto.Enabled ?? defaults.Enabled, PollInterval = dto.PollInterval ?? defaults.PollInterval, }; } /// /// Map the optional HandleRecycle config section onto /// . Missing section keeps the disabled default. /// private static FocasHandleRecycleOptions BuildHandleRecycle(FocasHandleRecycleDto? dto) { if (dto is null) return new FocasHandleRecycleOptions(); var defaults = new FocasHandleRecycleOptions(); return new FocasHandleRecycleOptions { Enabled = dto.Enabled ?? defaults.Enabled, Interval = dto.Interval ?? defaults.Interval, }; } 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? Series { get; init; } public int? TimeoutMs { get; init; } public List? Devices { get; init; } public List? Tags { get; init; } public FocasProbeDto? Probe { get; init; } public FocasFixedTreeDto? FixedTree { get; init; } public FocasAlarmProjectionDto? AlarmProjection { get; init; } public FocasHandleRecycleDto? HandleRecycle { 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; } } /// /// Optional FixedTree config section. Interval fields are JSON /// strings ("hh:mm:ss[.fff]") — System.Text.Json /// parses these natively. /// internal sealed class FocasFixedTreeDto { public bool? Enabled { get; init; } public TimeSpan? PollInterval { get; init; } public TimeSpan? ProgramPollInterval { get; init; } public TimeSpan? TimerPollInterval { get; init; } } /// Optional AlarmProjection config section. internal sealed class FocasAlarmProjectionDto { public bool? Enabled { get; init; } public TimeSpan? PollInterval { get; init; } } /// Optional HandleRecycle config section. internal sealed class FocasHandleRecycleDto { public bool? Enabled { get; init; } public TimeSpan? Interval { get; init; } } }