122 lines
5.6 KiB
C#
122 lines
5.6 KiB
C#
using System.Diagnostics;
|
|
using System.Net.Sockets;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
|
|
|
/// <summary>
|
|
/// Modbus FC03 probe for the <see cref="ModbusDriverOptions"/>-shaped driver config.
|
|
/// Opens a TCP connection to the configured endpoint and sends a one-shot FC03
|
|
/// (Read Holding Registers, qty 1 @ address 0) handshake. A normal FC03 response
|
|
/// or a Modbus exception PDU both confirm a live Modbus device (green + latency);
|
|
/// TCP failure surfaces the SocketError; a Modbus-level handshake failure after
|
|
/// TCP succeeds surfaces a targeted message; timeout surfaces "timed out after Ns."
|
|
/// </summary>
|
|
public sealed class ModbusDriverProbe : IDriverProbe
|
|
{
|
|
private static readonly JsonSerializerOptions _opts = new()
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
|
Converters = { new JsonStringEnumConverter() },
|
|
};
|
|
|
|
// FC03 Read Holding Registers: function=0x03, addr-hi=0, addr-lo=0, qty-hi=0, qty-lo=1
|
|
private static readonly byte[] Fc03Pdu = [0x03, 0x00, 0x00, 0x00, 0x01];
|
|
|
|
/// <inheritdoc />
|
|
public string DriverType => "Modbus";
|
|
|
|
/// <summary>The parsed probe target — host/port/unitId + the effective connection timeout, all read
|
|
/// from the SAME factory DTO shape (<c>ModbusDriverConfigDto</c>) the driver factory parses, so
|
|
/// <c>timeoutMs</c> binds identically to the factory (R2-11, 05/CONV-2; the OpcUaClient parity rule).</summary>
|
|
internal readonly record struct ProbeTarget(string Host, int Port, byte UnitId, TimeSpan Timeout);
|
|
|
|
/// <summary>Parse the driver config JSON into a <see cref="ProbeTarget"/> using the factory DTO shape.
|
|
/// Returns a null target + an error message when the JSON is invalid or has no host/port. The effective
|
|
/// timeout mirrors the factory (<c>TimeSpan.FromMilliseconds(dto.TimeoutMs ?? …)</c>); when the config
|
|
/// omits <c>timeoutMs</c> the caller's <paramref name="fallbackTimeout"/> is used.</summary>
|
|
/// <param name="configJson">The driver config JSON (factory DTO shape).</param>
|
|
/// <param name="fallbackTimeout">The timeout used when the config omits <c>timeoutMs</c>.</param>
|
|
/// <returns>The parsed target, or a null target with an error string.</returns>
|
|
internal static (ProbeTarget? Target, string? Error) TryParseProbeTarget(string configJson, TimeSpan fallbackTimeout)
|
|
{
|
|
ModbusDriverFactoryExtensions.ModbusDriverConfigDto? dto;
|
|
try { dto = JsonSerializer.Deserialize<ModbusDriverFactoryExtensions.ModbusDriverConfigDto>(configJson, _opts); }
|
|
catch (Exception ex) { return (null, $"Config JSON is invalid: {ex.Message}"); }
|
|
if (dto is null) return (null, "Config JSON deserialized to null.");
|
|
|
|
var port = dto.Port ?? 502;
|
|
if (string.IsNullOrWhiteSpace(dto.Host) || port <= 0)
|
|
return (null, "Config has no host/port to probe.");
|
|
|
|
var timeout = dto.TimeoutMs is { } ms && ms > 0 ? TimeSpan.FromMilliseconds(ms) : fallbackTimeout;
|
|
return (new ProbeTarget(dto.Host!, port, dto.UnitId ?? 1, timeout), null);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
|
|
{
|
|
var (target, error) = TryParseProbeTarget(configJson, timeout);
|
|
if (target is not { } t) return new(false, error!, null);
|
|
|
|
var host = t.Host;
|
|
var port = t.Port;
|
|
var unitId = t.UnitId;
|
|
// Honour the config's timeoutMs (factory parity) — the caller's timeout is the fallback.
|
|
timeout = t.Timeout;
|
|
|
|
var sw = Stopwatch.StartNew();
|
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
cts.CancelAfter(timeout);
|
|
|
|
// Phase 1 — TCP connect (using ModbusTcpTransport which handles IPv4 preference).
|
|
// autoReconnect=false: this is a one-shot probe, no retry loops.
|
|
var transport = new ModbusTcpTransport(host, port, timeout, autoReconnect: false);
|
|
await using (transport.ConfigureAwait(false))
|
|
{
|
|
try
|
|
{
|
|
await transport.ConnectAsync(cts.Token).ConfigureAwait(false);
|
|
}
|
|
catch (SocketException ex)
|
|
{
|
|
return new(false, $"Connect failed: {ex.SocketErrorCode}", null);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new(false, ex.Message, null);
|
|
}
|
|
|
|
// Phase 2 — FC03 handshake. TCP is up; now prove a Modbus device is answering.
|
|
try
|
|
{
|
|
await transport.SendAsync(unitId, Fc03Pdu, cts.Token).ConfigureAwait(false);
|
|
sw.Stop();
|
|
return new(true, "Modbus FC03 OK", sw.Elapsed);
|
|
}
|
|
catch (ModbusException)
|
|
{
|
|
// Device replied with an exception PDU — it IS a real Modbus device.
|
|
sw.Stop();
|
|
return new(true, "Modbus FC03 OK (device returned exception PDU)", sw.Elapsed);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
sw.Stop();
|
|
return new(false, $"Reachable at {host}:{port} but Modbus FC03 handshake failed: {ex.Message}", null);
|
|
}
|
|
}
|
|
}
|
|
}
|