b75e013d0a
The old AdminUI cannot author the per-driver transport key (#576), so the 'pin before upgrading' recipe had no working path. A node-level appsetting (env Drivers__AbCip__DefaultTransport) now supplies the default for AbCip drivers with no key: explicit key > node default > compiled Native. Bound and validated unconditionally (bad value fails host start); the driver factory and the Test Connect probe both read it so they agree. AbCip.md upgrade recipe rewritten around the key; Configuration.md documents it. Also documents first-wins for AllowPacking/probe and host-alias throttles (final review M6) and moves the status-bit note out of AbCip.md (M5). Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
212 lines
10 KiB
C#
212 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.AbCip.PlcFamilies;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
|
|
|
/// <summary>
|
|
/// Two-phase Test-Connect probe for the <see cref="AbCipDriverOptions"/>-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 tag runtime
|
|
/// against the same device to open a real EIP session and perform a CIP Forward Open —
|
|
/// confirming the remote endpoint actually speaks EtherNet/IP CIP, not just accepts TCP.
|
|
/// A device that accepts the TCP connection but is not a CIP controller (wrong CIP path,
|
|
/// non-CIP server) returns <c>Ok = false</c> with a "handshake failed" message instead of
|
|
/// a false-positive green tick.
|
|
/// <para>
|
|
/// Status-code discrimination: <c>AbStatus.Ok</c> and <c>AbStatus.NotFound</c> mean the controller
|
|
/// answered the CIP session (a non-existent probe tag name still proves CIP connectivity) and are
|
|
/// treated as "reachable - Ok = true". Every other status (timeout, bad connection, bad reply,
|
|
/// partial, abort, ...) is a transport or session failure: "handshake failed - Ok = false".
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class AbCipDriverProbe : IDriverProbe
|
|
{
|
|
private static readonly JsonSerializerOptions _opts = new()
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
|
Converters = { new JsonStringEnumConverter() },
|
|
};
|
|
|
|
private readonly IAbCipTagFactory? _tagFactory;
|
|
private readonly AbCipTransport _defaultTransport;
|
|
|
|
/// <summary>Initializes a new instance of the <see cref="AbCipDriverProbe"/> class.</summary>
|
|
/// <param name="tagFactory">Factory for the probe's tag runtime; when null the config's
|
|
/// <c>Transport</c> selects the native or libplctag-backed one.</param>
|
|
/// <param name="defaultTransport">The node-level default for a config with no <c>transport</c> key — the
|
|
/// SAME value the node's driver factory uses, so Test Connect probes the stack the driver would run.</param>
|
|
public AbCipDriverProbe(IAbCipTagFactory? tagFactory = null,
|
|
AbCipTransport defaultTransport = AbCipTransportResolver.Default)
|
|
{
|
|
_tagFactory = tagFactory;
|
|
_defaultTransport = defaultTransport;
|
|
}
|
|
|
|
/// <summary>The node-level default this probe resolves an absent key to (wiring tests).</summary>
|
|
public AbCipTransport DefaultTransport => _defaultTransport;
|
|
|
|
/// <inheritdoc />
|
|
public string DriverType => "AbCip";
|
|
|
|
/// <inheritdoc />
|
|
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
|
|
{
|
|
AbCipDriverOptions? opts;
|
|
try { opts = JsonSerializer.Deserialize<AbCipDriverOptions>(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 CIP Forward Open 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: CIP session via libplctag Tag.InitializeAsync. Open a real EIP session +
|
|
// Forward Open to confirm the remote endpoint speaks CIP, not just TCP.
|
|
var profile = AbCipPlcFamilyProfile.ForFamily(firstDevice?.PlcFamily ?? AbCipPlcFamily.ControlLogix);
|
|
|
|
// Prefer the first authored tag path as the probe tag name (it must exist on the PLC). v3: the
|
|
// tags arrive as RawTagEntry blobs — map each through the pure factory to recover its TagPath +
|
|
// device routing key.
|
|
//
|
|
// The fallback is NOT a system attribute the controller can read, despite its name and despite
|
|
// what this comment used to claim: libplctag's CIP special-tag dispatch knows only @raw, @tags
|
|
// and @udt/, so "@raw_cpu_type" is encoded and sent as an ORDINARY symbolic name. That is what
|
|
// makes it a usable probe — no controller defines it, so the controller answers CIP 0x04 ("tag
|
|
// does not exist") and the answer itself proves the session is live. IsReachableStatus is what
|
|
// turns that into a green tick, so a device with no authored tags reports reachable, not failed.
|
|
// Do not "fix" this to @tags: libplctag builds a tag-listing tag WITHOUT any network I/O, so it
|
|
// reports OK against any TCP listener — measured green against a Mosquitto broker.
|
|
var mappedTags = opts.RawTags
|
|
.Select(e => AbCipTagDefinitionFactory.FromTagConfig(e.TagConfig, e.RawPath, out var d)
|
|
? (Ok: true, Device: e.DeviceName, d.TagPath)
|
|
: (Ok: false, Device: e.DeviceName, TagPath: null!))
|
|
.Where(x => x.Ok)
|
|
.ToList();
|
|
var tagName = mappedTags.FirstOrDefault(
|
|
x => string.Equals(x.Device, firstDevice?.HostAddress, StringComparison.OrdinalIgnoreCase)
|
|
|| (firstDevice?.DeviceName is { } dn && string.Equals(x.Device, dn, StringComparison.OrdinalIgnoreCase)))
|
|
.TagPath
|
|
?? mappedTags.FirstOrDefault().TagPath
|
|
?? "@raw_cpu_type";
|
|
|
|
var p = new AbCipTagCreateParams(
|
|
Gateway: parsed.Gateway,
|
|
Port: parsed.Port,
|
|
CipPath: parsed.CipPath,
|
|
LibplctagPlcAttribute: profile.LibplctagPlcAttribute,
|
|
TagName: tagName,
|
|
Timeout: timeout,
|
|
AllowPacking: firstDevice?.AllowPacking ?? profile.SupportsRequestPacking,
|
|
ConnectionSize: firstDevice?.ConnectionSize ?? profile.DefaultConnectionSize,
|
|
UseConnectedMessaging: profile.SupportsConnectedMessaging);
|
|
|
|
var rt = (_tagFactory ?? AbCipTransportFactories.For(AbCipTransportResolver.Resolve(opts, _defaultTransport)).Tags).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
|
|
? "CIP session OK"
|
|
: "CIP 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 CIP 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 (IsReachableException(ex))
|
|
{
|
|
sw.Stop();
|
|
return new(true, "CIP session OK (controller reachable; probe tag not found)", sw.Elapsed);
|
|
}
|
|
|
|
return new(false, $"Reachable at {host}:{port} but CIP handshake failed: {ex.Message}", null);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new(false, $"Reachable at {host}:{port} but CIP handshake failed: {ex.Message}", null);
|
|
}
|
|
finally
|
|
{
|
|
rt.Dispose();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns <c>true</c> for transport statuses that indicate the CIP controller answered
|
|
/// but the probe tag name was not found or did not match — sufficient to confirm CIP
|
|
/// reachability. Returns <c>true</c> for <c>Ok</c> (tag was found).
|
|
/// </summary>
|
|
internal static bool IsReachableStatus(AbStatus s) => s is AbStatus.Ok or AbStatus.NotFound;
|
|
|
|
/// <summary>
|
|
/// Decides whether an <see cref="AbTransportException"/> is a "controller answered CIP but the
|
|
/// probe tag did not resolve" condition (reachable) or a session / transport failure
|
|
/// (unreachable), deferring to <see cref="IsReachableStatus"/> — one classification, not two.
|
|
/// </summary>
|
|
/// <param name="ex">The exception thrown out of <c>InitializeAsync</c>.</param>
|
|
/// <returns><c>true</c> when the controller answered; <c>false</c> for a transport/session failure.</returns>
|
|
internal static bool IsReachableException(AbTransportException ex)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(ex);
|
|
return IsReachableStatus(ex.Status);
|
|
}
|
|
|
|
private static (AbCipHostAddress? parsed, AbCipDeviceOptions? device) ExtractTarget(AbCipDriverOptions 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 = AbCipHostAddress.TryParse(firstDevice.HostAddress);
|
|
return (parsed, firstDevice);
|
|
}
|
|
}
|