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; /// /// 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 libplctag /// 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 Ok = false with a "handshake failed" message instead of /// a false-positive green tick. /// /// Status-code discrimination: transport statuses that indicate the controller answered /// the PCCC session but could not resolve the probe tag /// () are treated as "reachable — Ok = true" because /// a non-existent tag name proves PCCC connectivity. Statuses that indicate a transport or /// session failure (, , /// , , ) are treated as /// "handshake failed — Ok = false". All other error statuses default to /// "handshake failed" (conservative). /// /// public sealed class AbLegacyDriverProbe : IDriverProbe { private readonly IAbLegacyTagFactory? _tagFactory; /// Initializes a new instance of the class. /// The tag factory used for the PCCC handshake; when null the config's /// Transport selects the native or libplctag-backed one. public AbLegacyDriverProbe(IAbLegacyTagFactory? tagFactory = null) => _tagFactory = tagFactory; private static readonly JsonSerializerOptions _opts = new() { PropertyNameCaseInsensitive = true, UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, Converters = { new JsonStringEnumConverter() }, }; /// public string DriverType => "AbLegacy"; /// public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) { AbLegacyDriverOptions? 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 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(); } } /// /// Returns true for statuses that indicate the PCCC controller answered but the /// probe tag was not found — sufficient to confirm PCCC reachability. Also true for /// (tag was found). /// /// The transport status. /// Whether the status proves the controller answered. 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); } /// /// 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 /// TagConfig blob is mapped through /// to recover the PCCC address. Returns when no tag maps, so the caller /// falls back to the status file (S:0). /// /// The deserialised driver options. /// The device being probed (matched by DeviceName / HostAddress). /// A probeable PCCC address, or . 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; } }