Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverFactoryExtensions.cs
T

220 lines
12 KiB
C#

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;
/// <summary>
/// Static factory registration helper for <see cref="S7Driver"/>. Server's Program.cs
/// calls <see cref="Register"/> once at startup; the bootstrapper then materialises S7
/// DriverInstance rows from the central config DB into live driver instances. Mirrors
/// <c>GalaxyProxyDriverFactoryExtensions</c>.
/// </summary>
public static class S7DriverFactoryExtensions
{
public const string DriverTypeName = "S7";
/// <summary>Registers the S7 driver factory with the registry.</summary>
/// <remarks>
/// The optional <paramref name="loggerFactory"/> is captured at registration time and used to
/// build an <see cref="ILogger{S7Driver}"/> 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).
/// </remarks>
/// <param name="registry">The driver factory registry.</param>
/// <param name="loggerFactory">Optional logger factory for driver instances.</param>
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
{
ArgumentNullException.ThrowIfNull(registry);
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
}
/// <summary>Creates a new S7 driver instance from configuration.</summary>
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
/// <param name="driverConfigJson">The JSON configuration for the driver.</param>
/// <returns>A newly created S7 driver instance.</returns>
/// <param name="loggerFactory">Optional logger factory for the driver instance.</param>
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<S7Driver>());
}
/// <summary>
/// Parse a driver-config JSON document into a strongly-typed <see cref="S7DriverOptions"/>.
/// Shared by the factory (instance creation) and by <see cref="S7Driver.InitializeAsync"/>
/// / <see cref="S7Driver.ReinitializeAsync"/> so a config change delivered through the
/// <c>IDriver</c> contract is actually applied.
/// </summary>
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
/// <param name="driverConfigJson">The JSON configuration for the driver.</param>
/// <returns>Parsed S7 driver options.</returns>
internal static S7DriverOptions ParseOptions(string driverInstanceId, string driverConfigJson)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
var dto = JsonSerializer.Deserialize<S7DriverConfigDto>(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<S7ConnectionType>(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<S7CpuType>(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<T>(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 ?? "<unnamed>"}' in '{driverInstanceId}' missing {field}");
}
return Enum.TryParse<T>(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<T>())}");
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
/// <summary>Data transfer object for S7 driver configuration.</summary>
internal sealed class S7DriverConfigDto
{
/// <summary>Gets the PLC host address.</summary>
public string? Host { get; init; }
/// <summary>Gets the PLC port.</summary>
public int? Port { get; init; }
/// <summary>Gets the CPU type name.</summary>
public string? CpuType { get; init; }
/// <summary>Gets the rack number.</summary>
public short? Rack { get; init; }
/// <summary>Gets the slot number.</summary>
public short? Slot { get; init; }
/// <summary>Gets the local TSAP override — hex text (<c>"01.00"</c>) or a 16-bit number.</summary>
public JsonElement? LocalTsap { get; init; }
/// <summary>Gets the remote TSAP override — hex text (<c>"03.01"</c>) or a 16-bit number.</summary>
public JsonElement? RemoteTsap { get; init; }
/// <summary>Gets the connection-type name (PG / OP / S7Basic); a string so AdminUI-authored configs bind.</summary>
public string? ConnectionType { get; init; }
/// <summary>Gets the connection timeout in milliseconds.</summary>
public int? TimeoutMs { get; init; }
/// <summary>Gets the authored raw tags (RawPath + TagConfig blob + WriteIdempotent) the deploy
/// artifact delivers. The driver maps each through <see cref="S7TagDefinitionFactory"/> at Initialize.</summary>
public List<RawTagEntry>? RawTags { get; init; }
/// <summary>Gets the probe configuration.</summary>
public S7ProbeDto? Probe { get; init; }
/// <summary>Gets the largest hole the read planner reads through when merging tags into one
/// block request; <c>-1</c> disables block-read grouping and anything below it is rejected.
/// Null keeps the default.</summary>
public int? SpanReadMaxGapBytes { get; init; }
/// <summary>Gets the largest single planned block read in bytes. Null keeps the default.</summary>
public int? SpanReadMaxSpanBytes { get; init; }
}
/// <summary>Data transfer object for S7 probe configuration.</summary>
internal sealed class S7ProbeDto
{
/// <summary>Gets a value indicating whether probing is enabled.</summary>
public bool? Enabled { get; init; }
/// <summary>Gets the probe interval in milliseconds.</summary>
public int? IntervalMs { get; init; }
/// <summary>Gets the probe timeout in milliseconds.</summary>
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).
}
}