using System.IO; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Import; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; /// /// Static factory registration helper for . Server's Program.cs /// calls once at startup; the bootstrapper (task #248) then /// materialises AB Legacy DriverInstance rows from the central config DB into live /// driver instances. Mirrors GalaxyProxyDriverFactoryExtensions. /// public static class AbLegacyDriverFactoryExtensions { public const string DriverTypeName = "AbLegacy"; public static void Register(DriverFactoryRegistry registry) { ArgumentNullException.ThrowIfNull(registry); registry.Register(DriverTypeName, CreateInstance); } internal static AbLegacyDriver CreateInstance(string driverInstanceId, string driverConfigJson) { ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson); var dto = JsonSerializer.Deserialize(driverConfigJson, JsonOptions) ?? throw new InvalidOperationException( $"AB Legacy driver config for '{driverInstanceId}' deserialised to null"); var options = new AbLegacyDriverOptions { Devices = dto.Devices is { Count: > 0 } ? [.. dto.Devices.Select(d => new AbLegacyDeviceOptions( HostAddress: d.HostAddress ?? throw new InvalidOperationException( $"AB Legacy config for '{driverInstanceId}' has a device missing HostAddress"), PlcFamily: ParseEnum(d.PlcFamily, driverInstanceId, "PlcFamily", fallback: AbLegacyPlcFamily.Slc500), DeviceName: d.DeviceName, // PR 9 — per-device timeout / retry overrides. Device-level wins over driver-wide. Timeout: d.TimeoutMs is int devMs ? TimeSpan.FromMilliseconds(devMs) : null, Retries: d.Retries))] : [], Tags = dto.Tags is { Count: > 0 } ? [.. dto.Tags.Select(t => new AbLegacyTagDefinition( Name: t.Name ?? throw new InvalidOperationException( $"AB Legacy config for '{driverInstanceId}' has a tag missing Name"), DeviceHostAddress: t.DeviceHostAddress ?? throw new InvalidOperationException( $"AB Legacy tag '{t.Name}' in '{driverInstanceId}' missing DeviceHostAddress"), Address: t.Address ?? throw new InvalidOperationException( $"AB Legacy tag '{t.Name}' in '{driverInstanceId}' missing Address"), DataType: ParseEnum(t.DataType, driverInstanceId, "DataType", tagName: t.Name), Writable: t.Writable ?? true, WriteIdempotent: t.WriteIdempotent ?? false, ArrayLength: t.ArrayLength, AbsoluteDeadband: t.AbsoluteDeadband, PercentDeadband: t.PercentDeadband))] : [], Probe = new AbLegacyProbeOptions { Enabled = dto.Probe?.Enabled ?? true, Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_000), Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000), ProbeAddress = dto.Probe?.ProbeAddress ?? "S:0", }, Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 2_000), // PR 9 — driver-wide retry default. null ≡ 0 retries (single attempt). Per-device // Retries on AbLegacyDeviceOptions still wins. Retries = dto.Retries, }; return new AbLegacyDriver(options, driverInstanceId); } /// /// ablegacy-11 / #254 — append RSLogix CSV symbol-export rows to /// as entries bound to /// . Returns a new /// with the imported tags concatenated onto the existing Tags list — useful both /// at startup-time (server-side bootstrap that wants to seed a device's address space /// from a customer-supplied CSV) and from the CLI (import-rslogix emits the /// resulting JSON fragment for hand-merging into an appsettings file). /// /// /// /// The importer is permissive by default — malformed rows are logged and skipped; /// the resulting counts surface on /// for callers that want to assert "we got the row count /// we expected". /// /// /// RSLogix 500's .RSS + RSLogix 5's .RSP binary project files are /// out of scope for v1 — the binary format is proprietary and undocumented; no /// libplctag or community parser exists. Customers must export to text/CSV via /// RSLogix's "Tools → Database → Save" or "Database Export" before pointing the /// importer at the file. See docs/drivers/AbLegacy-RSLogix-Import.md. /// /// public static AbLegacyDriverOptions AddRsLogixImport( this AbLegacyDriverOptions options, string path, string deviceHostAddress, out RsLogixImportResult result, ImportOptions? importOptions = null, ILogger? logger = null) { ArgumentNullException.ThrowIfNull(options); ArgumentException.ThrowIfNullOrWhiteSpace(path); ArgumentException.ThrowIfNullOrWhiteSpace(deviceHostAddress); using var stream = File.OpenRead(path); var importer = new RsLogixSymbolImport(logger ?? NullLogger.Instance); result = importer.Parse(stream, deviceHostAddress, importOptions); // Concat onto whatever's already on the options — the importer is additive so // hand-edited Tags rows (e.g., system-status fields not surfaced by RSLogix) keep // sitting alongside the bulk-imported symbol rows. Use init-syntax with-expression // so the returned options keeps every other field (Devices, Probe, Timeout, …) // untouched. var merged = new List(options.Tags.Count + result.Tags.Count); merged.AddRange(options.Tags); merged.AddRange(result.Tags); return new AbLegacyDriverOptions { Devices = options.Devices, Tags = merged, Probe = options.Probe, Timeout = options.Timeout, Retries = options.Retries, }; } /// /// CLI-friendly overload that returns the alongside /// the modified options as a tuple. Mirrors but avoids /// the out parameter for call sites that prefer pattern-matched destructuring. /// public static (AbLegacyDriverOptions Options, RsLogixImportResult Result) AddRsLogixImportWithResult( this AbLegacyDriverOptions options, string path, string deviceHostAddress, ImportOptions? importOptions = null, ILogger? logger = null) { var updated = options.AddRsLogixImport(path, deviceHostAddress, out var result, importOptions, logger); return (updated, result); } private static T ParseEnum(string? raw, string driverInstanceId, string field, string? tagName = null, T? fallback = null) where T : struct, Enum { if (string.IsNullOrWhiteSpace(raw)) { if (fallback.HasValue) return fallback.Value; throw new InvalidOperationException( $"AB Legacy {(tagName is null ? "config" : $"tag '{tagName}'")} in '{driverInstanceId}' missing {field}"); } return Enum.TryParse(raw, ignoreCase: true, out var v) ? v : throw new InvalidOperationException( $"AB Legacy {(tagName is null ? "config" : $"tag '{tagName}'")} has unknown {field} '{raw}'. " + $"Expected one of {string.Join(", ", Enum.GetNames())}"); } private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, ReadCommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true, }; internal sealed class AbLegacyDriverConfigDto { public int? TimeoutMs { get; init; } /// /// PR 9 — driver-wide retry count for transient BadCommunicationError reads. /// null0 (single attempt). A per-device override on /// wins. /// public int? Retries { get; init; } public List? Devices { get; init; } public List? Tags { get; init; } public AbLegacyProbeDto? Probe { get; init; } } internal sealed class AbLegacyDeviceDto { public string? HostAddress { get; init; } public string? PlcFamily { get; init; } public string? DeviceName { get; init; } /// /// PR 9 — optional per-device timeout in ms. Wins over the driver-wide /// . Tune this per chassis: SLC 5/01 /// RS-232 ≈ 5000, SLC 5/05 ≈ 2000, MicroLogix 1100 ≈ 3000. /// public int? TimeoutMs { get; init; } /// /// PR 9 — optional per-device retry count for transient BadCommunicationError /// reads. Wins over the driver-wide . /// null at both levels = single attempt. /// public int? Retries { get; init; } } internal sealed class AbLegacyTagDto { 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; } /// /// PR 7 — optional override for the parsed array suffix. When set and > 1 the /// driver issues a single contiguous PCCC block read for N elements. /// public int? ArrayLength { get; init; } /// /// PR 8 — optional absolute change filter for numeric tags. OnDataChange is /// suppressed unless |new - prev| >= AbsoluteDeadband. Booleans bypass; /// strings + status changes always publish. /// public double? AbsoluteDeadband { get; init; } /// /// PR 8 — optional percent-of-previous change filter for numeric tags. /// OnDataChange is suppressed unless |new - prev| >= |prev * Percent / 100|. /// prev == 0 always publishes (avoids division-by-zero). /// public double? PercentDeadband { get; init; } } internal sealed class AbLegacyProbeDto { public bool? Enabled { get; init; } public int? IntervalMs { get; init; } public int? TimeoutMs { get; init; } public string? ProbeAddress { get; init; } } }