using System.Text.Json; using System.Text.Json.Serialization; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; 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. /// /// The driver factory registry to register with. public static void Register(DriverFactoryRegistry registry) { ArgumentNullException.ThrowIfNull(registry); registry.Register(DriverTypeName, CreateInstance); } /// /// Creates a new instance from the supplied configuration ID and JSON. /// /// The unique driver instance identifier. /// The driver configuration JSON string. /// A configured instance. 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), PositionDecimalPlaces: d.PositionDecimalPlaces ?? 0))] : [], // v3: the deploy artifact (Wave C) populates rawTags with the cluster's authored raw FOCAS // tags (RawPath + TagConfig blob + WriteIdempotent + DeviceName). The driver maps each // RawTagEntry's TagConfig blob through FocasTagDefinitionFactory at Initialize and routes it to // its device by DeviceName; the legacy pre-declared DTO tag path is retired. RawTags = dto.RawTags is { Count: > 0 } ? [.. dto.RawTags] : [], Probe = new FocasProbeOptions { Enabled = dto.Probe?.Enabled ?? true, Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_000), Timeout = PositiveTimeoutOrDefault(dto.Probe?.TimeoutMs, 2_000), }, Timeout = PositiveTimeoutOrDefault(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); } /// /// Maps an operator-supplied timeout (ms) to a positive , substituting /// for a null / zero / negative value. A non-positive /// Timeout would make DISABLE its per-call /// wall-clock ceiling (falling back to the caller's long-lived poll token), reintroducing the /// exact unbounded-read-on-a-frozen-peer wedge the S7 R2-01 read-leg fix eliminated. Clamping /// here at the config boundary guarantees the deadline can never be authored away. /// /// The operator-supplied timeout in milliseconds, or null. /// The default applied when is null or non-positive. /// A strictly positive timeout. private static TimeSpan PositiveTimeoutOrDefault(int? ms, int defaultMs) => TimeSpan.FromMilliseconds(ms is int v and > 0 ? v : defaultMs); /// /// Builds the appropriate based on the config DTO's backend selection. /// /// The driver configuration DTO. /// The driver instance identifier for error reporting. /// A configured instance. 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 readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, ReadCommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true, }; /// /// Reads a JSON property as a string, tolerating a JSON number token as well. The /// AdminUI persists the FOCAS Series enum as its integer value (e.g. "series":6), /// while this DTO models Series as a string handed to /// (Enum.TryParse accepts the numeric form). Without this, System.Text.Json throws /// "Cannot get the value of a token type 'Number' as a string" on the bare number and the /// driver falls back to a stub. Accepts string / number / null and emits a string. /// internal sealed class FlexibleStringConverter : JsonConverter { /// public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => reader.TokenType switch { JsonTokenType.String => reader.GetString(), JsonTokenType.Number => reader.TryGetInt64(out var n) ? n.ToString(System.Globalization.CultureInfo.InvariantCulture) : reader.GetDouble().ToString(System.Globalization.CultureInfo.InvariantCulture), JsonTokenType.Null => null, _ => throw new JsonException($"Expected string, number, or null but got {reader.TokenType}."), }; /// public override void Write(Utf8JsonWriter writer, string? value, JsonSerializerOptions options) { if (value is null) writer.WriteNullValue(); else writer.WriteStringValue(value); } } internal sealed class FocasDriverConfigDto { /// Gets or sets the FOCAS client factory backend name (e.g. "wire" or "stub"). public string? Backend { get; init; } /// Gets or sets the CNC series for this driver. [JsonConverter(typeof(FlexibleStringConverter))] public string? Series { get; init; } /// Gets or sets the operation timeout in milliseconds. public int? TimeoutMs { get; init; } /// Gets or sets the list of CNC devices to manage. public List? Devices { get; init; } /// /// Gets or sets the authored raw tags (RawPath + TagConfig blob + WriteIdempotent + DeviceName) /// the driver serves. Bound verbatim into . /// public List? RawTags { get; init; } /// Gets or sets the probe configuration options. public FocasProbeDto? Probe { get; init; } /// Gets or sets the fixed-tree configuration options. public FocasFixedTreeDto? FixedTree { get; init; } /// Gets or sets the alarm projection configuration options. public FocasAlarmProjectionDto? AlarmProjection { get; init; } /// Gets or sets the handle recycle configuration options. public FocasHandleRecycleDto? HandleRecycle { get; init; } } internal sealed class FocasDeviceDto { /// Gets or sets the hostname or IP address of the CNC. public string? HostAddress { get; init; } /// Gets or sets the logical device name. public string? DeviceName { get; init; } /// Gets or sets the CNC series for this device (overrides top-level series if provided). [JsonConverter(typeof(FlexibleStringConverter))] public string? Series { get; init; } /// /// Gets or sets the axis-position decimal places. cnc_rddynamic2 returns /// positions as scaled integers; the driver divides by 10^PositionDecimalPlaces /// so they surface in engineering units. Omitted / 0 = no scaling (legacy). /// public int? PositionDecimalPlaces { get; init; } } internal sealed class FocasProbeDto { /// Gets or sets a value indicating whether probing is enabled. public bool? Enabled { get; init; } /// Gets or sets the probe interval in milliseconds. public int? IntervalMs { get; init; } /// Gets or sets the probe timeout in milliseconds. 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 { /// Gets or sets a value indicating whether fixed-tree discovery is enabled. public bool? Enabled { get; init; } /// Gets or sets the poll interval for discovering fixed-tree items. public TimeSpan? PollInterval { get; init; } /// Gets or sets the poll interval for discovering program-related items. public TimeSpan? ProgramPollInterval { get; init; } /// Gets or sets the poll interval for discovering timer-related items. public TimeSpan? TimerPollInterval { get; init; } } /// Optional AlarmProjection config section. internal sealed class FocasAlarmProjectionDto { /// Gets or sets a value indicating whether alarm projection is enabled. public bool? Enabled { get; init; } /// Gets or sets the alarm poll interval. public TimeSpan? PollInterval { get; init; } } /// Optional HandleRecycle config section. internal sealed class FocasHandleRecycleDto { /// Gets or sets a value indicating whether handle recycling is enabled. public bool? Enabled { get; init; } /// Gets or sets the handle recycle interval. public TimeSpan? Interval { get; init; } } }