using System.Diagnostics; using System.Net.Sockets; using System.Text.Json; using System.Text.Json.Serialization; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; /// /// Two-phase Test-Connect probe for the -shaped driver config. /// Phase 1: bare TCP connect to the first device's FOCAS Ethernet address + port to quickly /// reject unreachable targets (preserves the original "Connect failed" / "timed out" /// messages). Phase 2: a real FOCAS session via the managed — the /// two-socket initiate handshake plus one sample read (cnc_statinfo). A handshake + /// read that succeeds confirms the remote endpoint is a real FOCAS CNC, not just a TCP /// listener. /// /// Why a wire-client probe (not FWLIB). The pure-managed wire client is the driver's /// only read backend (the FWLIB / out-of-process paths were retired in the Wire migration), so /// the probe must exercise the same path the driver actually uses. The previous probe issued /// the cnc_allclibhndl3 FWLIB P/Invoke and, on any host without the native library (the /// normal case — macOS dev boxes, Linux CI, and the Windows hosts that run the managed client), /// degraded to Ok=true "TCP reachability only". That made every bare TCP listener look /// HEALTHY — exactly how a Makino 31i-B looked "healthy" while no FOCAS data flowed. The wire /// probe reports HEALTHY only on a genuine FOCAS session + read. See /// docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md (Phase 8). /// /// /// The wire client honours the linked CTS (ct + CancelAfter(timeout)) and its /// reads are abort-bounded (see ), so the probe always returns /// within the timeout budget even against a host that accepts TCP then stalls. /// /// public sealed class FocasDriverProbe : IDriverProbe { private static readonly JsonSerializerOptions _opts = new() { PropertyNameCaseInsensitive = true, UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, Converters = { new JsonStringEnumConverter() }, }; /// public string DriverType => "FOCAS"; /// public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) { FocasDriverOptions? 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 (host, port) = ExtractTarget(opts); if (string.IsNullOrWhiteSpace(host) || port <= 0) return new(false, "Config has no host/port to probe.", null); var sw = Stopwatch.StartNew(); // Phase 1: bare TCP preflight — fast rejection for unreachable hosts. Messages unchanged. try { using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await socket.ConnectAsync(host, port, ct); } 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: real FOCAS session via the managed wire client — initiate handshake + one // sample read. Bounded by a linked CTS = ct + CancelAfter(budget); the wire reads are // abort-bounded so a TCP-accept-then-stall host can't hold the probe past the budget. using var sessionCts = CancellationTokenSource.CreateLinkedTokenSource(ct); var budget = timeout > TimeSpan.Zero ? timeout : TimeSpan.FromSeconds(1); sessionCts.CancelAfter(budget); try { await using var wire = new FocasWireClient(); await wire.ConnectAsync(host, port, budget, sessionCts.Token).ConfigureAwait(false); var status = await wire.ReadStatusAsync(sessionCts.Token, budget).ConfigureAwait(false); sw.Stop(); return status.IsOk ? new(true, $"FOCAS session OK at {host}:{port} (cnc_statinfo)", sw.Elapsed) : new(false, $"Reachable at {host}:{port} but FOCAS read failed: EW_{status.Rc}", null); } catch (OperationCanceledException) { return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null); } catch (FocasWireException ex) { // TCP-reachable but the FOCAS initiate/read failed — a listener that is not a CNC. return new(false, $"Reachable at {host}:{port} but FOCAS session failed: {ex.Message}", null); } } private static (string host, int port) ExtractTarget(FocasDriverOptions opts) { // Parse the first device's focas:// address to extract host + port. var firstDevice = opts.Devices.FirstOrDefault(); if (firstDevice is null) return (string.Empty, 0); var parsed = FocasHostAddress.TryParse(firstDevice.HostAddress); if (parsed is null) return (string.Empty, 0); return (parsed.Host, parsed.Port); } }