Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs
T

190 lines
8.6 KiB
C#

using System.Diagnostics;
using System.Net.Sockets;
using System.Text.Json;
using System.Text.Json.Serialization;
using libplctag;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>
/// Two-phase Test-Connect probe for the <see cref="AbLegacyDriverOptions"/>-shaped driver config.
/// Phase 1: bare TCP connect to the first device's gateway host + EtherNet/IP port to
/// quickly reject unreachable targets. Phase 2: initialises a libplctag <see cref="LibplctagLegacyTagRuntime"/>
/// against the same device to open a real PCCC-over-EIP session — confirming the remote
/// endpoint actually speaks PCCC, not just accepts TCP.
/// A device that accepts the TCP connection but is not a PCCC controller (wrong path,
/// non-PCCC server) returns <c>Ok = false</c> with a "handshake failed" message instead of
/// a false-positive green tick.
/// <para>
/// Status-code discrimination: libplctag statuses that indicate the controller answered
/// the PCCC session but could not resolve the probe tag
/// (<see cref="Status.ErrorNotFound"/>, <see cref="Status.ErrorNoMatch"/>,
/// <see cref="Status.ErrorBadDevice"/>) are treated as "reachable — Ok = true" because
/// a non-existent tag name proves PCCC connectivity. Statuses that indicate a transport or
/// session failure (<see cref="Status.ErrorTimeout"/>, <see cref="Status.ErrorBadConnection"/>,
/// <see cref="Status.ErrorBadGateway"/>, <see cref="Status.ErrorWinsock"/>,
/// <see cref="Status.ErrorOpen"/>, <see cref="Status.ErrorClose"/>,
/// <see cref="Status.ErrorRead"/>, <see cref="Status.ErrorWrite"/>,
/// <see cref="Status.ErrorBadReply"/>, <see cref="Status.ErrorRemoteErr"/>,
/// <see cref="Status.ErrorPartial"/>, <see cref="Status.ErrorAbort"/>) are treated as
/// "handshake failed — Ok = false". All other error statuses default to
/// "handshake failed" (conservative).
/// </para>
/// </summary>
public sealed class AbLegacyDriverProbe : IDriverProbe
{
private static readonly JsonSerializerOptions _opts = new()
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
};
/// <inheritdoc />
public string DriverType => "AbLegacy";
/// <inheritdoc />
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
AbLegacyDriverOptions? opts;
try { opts = JsonSerializer.Deserialize<AbLegacyDriverOptions>(configJson, _opts); }
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
var (parsed, firstDevice) = ExtractTarget(opts);
if (parsed is null || string.IsNullOrWhiteSpace(parsed.Gateway) || parsed.Port <= 0)
return new(false, "Config has no host/port to probe.", null);
var host = parsed.Gateway;
var port = parsed.Port;
// Bound both phases by the caller timeout, independent of `ct`, so a device that accepts
// TCP but stalls the PCCC session cannot hang the probe.
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(timeout);
// Phase 1: bare TCP preflight — fast rejection for unreachable hosts.
var sw = Stopwatch.StartNew();
try
{
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await socket.ConnectAsync(host, port, cts.Token);
}
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: PCCC session via libplctag LibplctagLegacyTagRuntime.InitializeAsync.
// Open a real PCCC-over-EIP session to confirm the remote endpoint speaks PCCC, not just TCP.
var profile = AbLegacyPlcFamilyProfile.ForFamily(firstDevice?.PlcFamily ?? AbLegacyPlcFamily.Slc500);
// Use the configured probe address (defaults to "S:0" — status file, first word).
// S:0 is present on all PCCC PLCs that support the status file, so a "not found" response
// still proves PCCC connectivity; a successful read is the happy path.
var tagName = opts.Probe.ProbeAddress
?? opts.Tags.FirstOrDefault(
t => string.Equals(t.DeviceHostAddress, firstDevice?.HostAddress, StringComparison.OrdinalIgnoreCase))
?.Address
?? opts.Tags.FirstOrDefault()?.Address
?? "S:0";
var p = new AbLegacyTagCreateParams(
Gateway: parsed.Gateway,
Port: parsed.Port,
CipPath: parsed.CipPath,
LibplctagPlcAttribute: profile.LibplctagPlcAttribute,
TagName: tagName,
Timeout: timeout);
var rt = new LibplctagLegacyTagRuntime(p);
try
{
await rt.InitializeAsync(cts.Token);
sw.Stop();
// InitializeAsync completed without throwing — either the tag was found (Ok) or
// libplctag returned a non-exception status. Check via GetStatus().
var statusCode = (Status)rt.GetStatus();
if (IsReachableStatus(statusCode))
{
var msg = statusCode == Status.Ok
? "PCCC session OK"
: "PCCC session OK (controller reachable; probe tag not found)";
return new(true, msg, sw.Elapsed);
}
// Non-Ok but non-exception: a session/transport error surfaced in the status word.
return new(false, $"Reachable at {host}:{port} but PCCC handshake failed: {statusCode}", null);
}
catch (OperationCanceledException)
{
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
}
catch (LibPlcTagException ex)
{
// LibPlcTagException carries the Status; classify as reachable vs transport failure.
if (IsReachableException(ex))
{
sw.Stop();
return new(true, "PCCC session OK (controller reachable; probe tag not found)", sw.Elapsed);
}
return new(false, $"Reachable at {host}:{port} but PCCC handshake failed: {ex.Message}", null);
}
catch (Exception ex)
{
return new(false, $"Reachable at {host}:{port} but PCCC handshake failed: {ex.Message}", null);
}
finally
{
rt.Dispose();
}
}
/// <summary>
/// Returns <c>true</c> for libplctag statuses that indicate the PCCC controller answered
/// but the probe tag name was not found or did not match — sufficient to confirm PCCC
/// reachability. Returns <c>true</c> for <see cref="Status.Ok"/> (tag was found).
/// </summary>
private static bool IsReachableStatus(Status s) => s is
Status.Ok or
Status.ErrorNotFound or
Status.ErrorNoMatch or
Status.ErrorBadDevice;
/// <summary>
/// Inspects a <see cref="LibPlcTagException"/> message to decide whether the error is a
/// "controller answered PCCC but tag not found" condition (reachable) versus a session /
/// transport failure (unreachable). Falls back to checking the exception message for
/// known tag-not-found phrases used by the libplctag native library.
/// </summary>
private static bool IsReachableException(LibPlcTagException ex)
{
// libplctag wraps the Status in the message as "STATUS_NOT_FOUND", "STATUS_NO_MATCH", etc.
var msg = ex.Message ?? string.Empty;
return msg.Contains("NOT_FOUND", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("NO_MATCH", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("BAD_DEVICE", StringComparison.OrdinalIgnoreCase);
}
private static (AbLegacyHostAddress? parsed, AbLegacyDeviceOptions? device) ExtractTarget(AbLegacyDriverOptions opts)
{
// Parse the first device's ab:// host address to extract the gateway IP + EIP port.
var firstDevice = opts.Devices.FirstOrDefault();
if (firstDevice is null) return (null, null);
var parsed = AbLegacyHostAddress.TryParse(firstDevice.HostAddress);
return (parsed, firstDevice);
}
}