c0fbe74693
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
208 lines
10 KiB
C#
208 lines
10 KiB
C#
using System.Diagnostics;
|
|
using System.Net.Sockets;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
|
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: transport statuses that indicate the controller answered
|
|
/// the PCCC session but could not resolve the probe tag
|
|
/// (<see cref="AbStatus.NotFound"/>) 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="AbStatus.Timeout"/>, <see cref="AbStatus.BadConnection"/>,
|
|
/// <see cref="AbStatus.BadReply"/>, <see cref="AbStatus.Partial"/>, <see cref="AbStatus.Abort"/>) are treated as
|
|
/// "handshake failed — Ok = false". All other error statuses default to
|
|
/// "handshake failed" (conservative).
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class AbLegacyDriverProbe : IDriverProbe
|
|
{
|
|
private readonly IAbLegacyTagFactory? _tagFactory;
|
|
|
|
/// <summary>Initializes a new instance of the <see cref="AbLegacyDriverProbe"/> class.</summary>
|
|
/// <param name="tagFactory">The tag factory used for the PCCC handshake; when null the config's
|
|
/// <c>Transport</c> selects the native or libplctag-backed one.</param>
|
|
public AbLegacyDriverProbe(IAbLegacyTagFactory? tagFactory = null) => _tagFactory = tagFactory;
|
|
|
|
private static readonly JsonSerializerOptions _opts = new()
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
|
Converters = { new JsonStringEnumConverter() },
|
|
};
|
|
|
|
/// <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
|
|
?? FirstProbeableTagAddress(opts, firstDevice)
|
|
?? "S:0";
|
|
|
|
// Normalise through the same helper as the driver's probe loop (#660): PLC-5 octal I/O -> decimal.
|
|
// An unparseable address is a clean failure here — the factory would otherwise throw outside the try.
|
|
var normalisedName = AbLegacyProbeAddress.Normalise(tagName, firstDevice?.PlcFamily ?? AbLegacyPlcFamily.Slc500);
|
|
if (normalisedName is null)
|
|
return new(false, $"Probe address '{tagName}' is not a valid AbLegacy address for this PLC family.", null);
|
|
|
|
var p = new AbLegacyTagCreateParams(
|
|
Gateway: parsed.Gateway,
|
|
Port: parsed.Port,
|
|
CipPath: parsed.CipPath,
|
|
LibplctagPlcAttribute: profile.LibplctagPlcAttribute,
|
|
TagName: normalisedName,
|
|
Timeout: timeout);
|
|
|
|
var factory = _tagFactory ?? (AbLegacyTransportResolver.Resolve(opts) == AbLegacyTransport.Native
|
|
? new NativeAbLegacyTagFactory()
|
|
: new LibplctagLegacyTagFactory());
|
|
var rt = factory.Create(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 = (AbStatus)rt.GetStatus();
|
|
if (IsReachableStatus(statusCode))
|
|
{
|
|
var msg = statusCode == AbStatus.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 (AbTransportException ex)
|
|
{
|
|
// AbTransportException carries the Status; classify as reachable vs transport failure.
|
|
if (IsReachableStatus(ex.Status))
|
|
{
|
|
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 statuses that indicate the PCCC controller answered but the
|
|
/// probe tag was not found — sufficient to confirm PCCC reachability. Also <c>true</c> for
|
|
/// <see cref="AbStatus.Ok"/> (tag was found).
|
|
/// </summary>
|
|
/// <param name="s">The transport status.</param>
|
|
/// <returns>Whether the status proves the controller answered.</returns>
|
|
internal static bool IsReachableStatus(AbStatus s) => s is AbStatus.Ok or AbStatus.NotFound;
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pick a PCCC address to probe from the authored raw tags: prefer a tag owned by the device
|
|
/// being probed, else any authored tag. v3 tags carry no typed address — the RawTagEntry's
|
|
/// <c>TagConfig</c> blob is mapped through <see cref="AbLegacyTagDefinitionFactory.FromTagConfig"/>
|
|
/// to recover the PCCC address. Returns <see langword="null"/> when no tag maps, so the caller
|
|
/// falls back to the status file (<c>S:0</c>).
|
|
/// </summary>
|
|
/// <param name="opts">The deserialised driver options.</param>
|
|
/// <param name="firstDevice">The device being probed (matched by DeviceName / HostAddress).</param>
|
|
/// <returns>A probeable PCCC address, or <see langword="null"/>.</returns>
|
|
private static string? FirstProbeableTagAddress(AbLegacyDriverOptions opts, AbLegacyDeviceOptions? firstDevice)
|
|
{
|
|
static string? Address(RawTagEntry e) =>
|
|
AbLegacyTagDefinitionFactory.FromTagConfig(e.TagConfig, e.RawPath, out var d) ? d.Address : null;
|
|
|
|
foreach (var e in opts.RawTags)
|
|
if ((string.Equals(e.DeviceName, firstDevice?.DeviceName, StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(e.DeviceName, firstDevice?.HostAddress, StringComparison.OrdinalIgnoreCase))
|
|
&& Address(e) is { } matched)
|
|
return matched;
|
|
foreach (var e in opts.RawTags)
|
|
if (Address(e) is { } any)
|
|
return any;
|
|
return null;
|
|
}
|
|
}
|