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; /// /// Two-phase Test-Connect probe for the -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 Ok = false with a "handshake failed" message instead of /// a false-positive green tick. /// /// Status-code discrimination: AbStatus.Ok and AbStatus.NotFound 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". /// /// 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; /// Initializes a new instance of the class. /// Factory for the probe's tag runtime; when null the config's /// Transport selects the native or libplctag-backed one. /// The node-level default for a config with no transport key — the /// SAME value the node's driver factory uses, so Test Connect probes the stack the driver would run. public AbCipDriverProbe(IAbCipTagFactory? tagFactory = null, AbCipTransport defaultTransport = AbCipTransportResolver.Default) { _tagFactory = tagFactory; _defaultTransport = defaultTransport; } /// The node-level default this probe resolves an absent key to (wiring tests). public AbCipTransport DefaultTransport => _defaultTransport; /// public string DriverType => "AbCip"; /// public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) { AbCipDriverOptions? opts; try { opts = JsonSerializer.Deserialize(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(); } } /// /// Returns true 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 true for Ok (tag was found). /// internal static bool IsReachableStatus(AbStatus s) => s is AbStatus.Ok or AbStatus.NotFound; /// /// Decides whether an is a "controller answered CIP but the /// probe tag did not resolve" condition (reachable) or a session / transport failure /// (unreachable), deferring to — one classification, not two. /// /// The exception thrown out of InitializeAsync. /// true when the controller answered; false for a transport/session failure. 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); } }