using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Hosting; namespace ZB.MOM.WW.OtOpcUa.Driver.S7; /// /// Static factory registration helper for . Server's Program.cs /// calls once at startup; the bootstrapper then materialises S7 /// DriverInstance rows from the central config DB into live driver instances. Mirrors /// GalaxyProxyDriverFactoryExtensions. /// public static class S7DriverFactoryExtensions { public const string DriverTypeName = "S7"; /// Registers the S7 driver factory with the registry. /// /// The optional is captured at registration time and used to /// build an per driver instance — without it the driver runs on /// the null logger and none of its diagnostics (connect/reconnect, skipped tags, poll faults) /// reach the node log (CONV-3). /// /// The driver factory registry. /// Optional logger factory for driver instances. public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null) { ArgumentNullException.ThrowIfNull(registry); registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory)); } /// Creates a new S7 driver instance from configuration. /// The unique identifier for the driver instance. /// The JSON configuration for the driver. /// A newly created S7 driver instance. /// Optional logger factory for the driver instance. internal static S7Driver CreateInstance( string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory = null) { ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson); return new S7Driver(ParseOptions(driverInstanceId, driverConfigJson), driverInstanceId, logger: loggerFactory?.CreateLogger()); } /// /// Parse a driver-config JSON document into a strongly-typed . /// Shared by the factory (instance creation) and by /// / so a config change delivered through the /// IDriver contract is actually applied. /// /// The unique identifier for the driver instance. /// The JSON configuration for the driver. /// Parsed S7 driver options. internal static S7DriverOptions ParseOptions(string driverInstanceId, string driverConfigJson) { ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson); var dto = JsonSerializer.Deserialize(driverConfigJson, JsonOptions) ?? throw new InvalidOperationException( $"S7 driver config for '{driverInstanceId}' deserialised to null"); if (string.IsNullOrWhiteSpace(dto.Host)) throw new InvalidOperationException( $"S7 driver config for '{driverInstanceId}' missing required Host"); // Block-read grouping knobs. There is no IValidateOptions machinery for S7, so this parse is // the single choke point every deployed config passes through — validate here or a nonsensical // value is accepted at deploy time and only misbehaves later, inside the read loop. var spanReadMaxGapBytes = dto.SpanReadMaxGapBytes ?? S7DriverOptions.DefaultSpanReadMaxGapBytes; var spanReadMaxSpanBytes = dto.SpanReadMaxSpanBytes ?? S7DriverOptions.DefaultSpanReadMaxSpanBytes; if (spanReadMaxSpanBytes < 1) throw new InvalidOperationException( $"S7 driver config for '{driverInstanceId}' has SpanReadMaxSpanBytes {spanReadMaxSpanBytes}; " + "must be >= 1 — it caps a single planned block read, so a non-positive cap admits none. " + "There is no upper bound: a value above the negotiated PDU is legal and degrades to " + "multi-request chunking inside S7.Net."); // -1 is the documented escape hatch (span grouping disabled — S7Driver enables spans on >= 0) // and 0 means "tolerate no gap", i.e. merge only strictly adjacent tags. Both stay legal. if (spanReadMaxGapBytes < -1) throw new InvalidOperationException( $"S7 driver config for '{driverInstanceId}' has SpanReadMaxGapBytes {spanReadMaxGapBytes}; " + "must be >= -1 — -1 disables span grouping and 0 merges only strictly adjacent tags."); // Optional TSAP / connection-type override. Validated here (the single choke point) so a typo'd // TSAP fails the deploy rather than silently connecting with the slot-derived default. var localTsap = NormalizeTsap(dto.LocalTsap, driverInstanceId, "LocalTsap"); var remoteTsap = NormalizeTsap(dto.RemoteTsap, driverInstanceId, "RemoteTsap"); S7ConnectionType? connectionType = string.IsNullOrWhiteSpace(dto.ConnectionType) ? null : ParseEnum(dto.ConnectionType, driverInstanceId, "ConnectionType"); if (remoteTsap is not null && connectionType is not null) throw new InvalidOperationException( $"S7 driver config for '{driverInstanceId}' {S7DriverOptions.TsapConflictDetail}"); return new S7DriverOptions { Host = dto.Host!, LocalTsap = localTsap, RemoteTsap = remoteTsap, ConnectionType = connectionType, Port = dto.Port ?? 102, CpuType = ParseEnum(dto.CpuType, driverInstanceId, "CpuType", fallback: S7CpuType.S71500), Rack = dto.Rack ?? 0, Slot = dto.Slot ?? 0, Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 5_000), // v3: the deploy artifact (Wave C) populates rawTags with the cluster's authored raw S7 // tags. The driver maps each RawTagEntry's TagConfig blob through S7TagDefinitionFactory // at Initialize; the legacy pre-declared DTO tag path (Name/Address → BuildTag) is retired. RawTags = dto.RawTags is { Count: > 0 } ? [.. dto.RawTags] : [], // Mapped explicitly like every other key on this DTO — the options object is never // deserialised directly — so omitting them here would leave the documented // "-1 disables grouping" escape hatch unreachable from a deployed config. SpanReadMaxGapBytes = spanReadMaxGapBytes, SpanReadMaxSpanBytes = spanReadMaxSpanBytes, Probe = new S7ProbeOptions { Enabled = dto.Probe?.Enabled ?? true, Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_000), Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000), // ProbeAddress removed — probe uses ReadStatusAsync, not a tag read. }, }; } private static string? NormalizeTsap(JsonElement? raw, string driverInstanceId, string field) { if (raw is not { } el || el.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) return null; string? text = el.ValueKind switch { JsonValueKind.String => el.GetString(), JsonValueKind.Number when el.TryGetUInt16(out var n) => S7Tsap.Format(n), _ => "?", }; if (string.IsNullOrWhiteSpace(text)) return null; if (!S7Tsap.TryParse(text, out var tsap)) throw new InvalidOperationException( $"S7 driver config for '{driverInstanceId}' has {field} '{text}'; expected two hex bytes " + "such as \"01.00\" or \"03.01\" (or a 16-bit number)."); return S7Tsap.Format(tsap); } 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( $"S7 tag '{tagName ?? ""}' in '{driverInstanceId}' missing {field}"); } return Enum.TryParse(raw, ignoreCase: true, out var v) ? v : throw new InvalidOperationException( $"S7 {(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, }; /// Data transfer object for S7 driver configuration. internal sealed class S7DriverConfigDto { /// Gets the PLC host address. public string? Host { get; init; } /// Gets the PLC port. public int? Port { get; init; } /// Gets the CPU type name. public string? CpuType { get; init; } /// Gets the rack number. public short? Rack { get; init; } /// Gets the slot number. public short? Slot { get; init; } /// Gets the local TSAP override — hex text ("01.00") or a 16-bit number. public JsonElement? LocalTsap { get; init; } /// Gets the remote TSAP override — hex text ("03.01") or a 16-bit number. public JsonElement? RemoteTsap { get; init; } /// Gets the connection-type name (PG / OP / S7Basic); a string so AdminUI-authored configs bind. public string? ConnectionType { get; init; } /// Gets the connection timeout in milliseconds. public int? TimeoutMs { get; init; } /// Gets the authored raw tags (RawPath + TagConfig blob + WriteIdempotent) the deploy /// artifact delivers. The driver maps each through at Initialize. public List? RawTags { get; init; } /// Gets the probe configuration. public S7ProbeDto? Probe { get; init; } /// Gets the largest hole the read planner reads through when merging tags into one /// block request; -1 disables block-read grouping and anything below it is rejected. /// Null keeps the default. public int? SpanReadMaxGapBytes { get; init; } /// Gets the largest single planned block read in bytes. Null keeps the default. public int? SpanReadMaxSpanBytes { get; init; } } /// Data transfer object for S7 probe configuration. internal sealed class S7ProbeDto { /// Gets a value indicating whether probing is enabled. public bool? Enabled { get; init; } /// Gets the probe interval in milliseconds. public int? IntervalMs { get; init; } /// Gets the probe timeout in milliseconds. public int? TimeoutMs { get; init; } // ProbeAddress removed from the configurable surface — the probe uses ReadStatusAsync // (CPU status), not a tag-address read. Config documents that previously set // probeAddress are safely ignored (unknown JSON fields are tolerated by the deserialiser). } }