using System.Diagnostics; using System.Net.Sockets; using System.Text.Json; using System.Text.Json.Serialization; using TwinCAT.Ads; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; /// /// Two-phase, degrade-guarded Test-Connect probe for the -shaped /// driver config. Phase 1: a bare TCP connect to the first device's AMS router host (first four /// octets of the AMS Net ID) on the AMS port — fast rejection of unreachable targets. Phase 2: /// a real ADS handshake — AdsClient.Connect(netId, port) + ReadStateAsync — to /// confirm the endpoint speaks ADS and report the controller's run-state, not just that a TCP /// socket opened. /// /// Outcome classification — three cases: /// /// /// ADS connected + ReadState OKOk=true, message "ADS state: {AdsState}" /// (e.g. "Run" / "Config" / "Stop"), with latency. /// /// /// Route/auth rejection from a reachable router — an /// (or a non-success ReadStateAsync result) whose means the /// router answered but won't let us in (e.g. , /// , , /// ) → Ok=false, message /// "Reachable at {host}:{port} but ADS handshake failed: {code} — check the target's ADS /// route table authorizes this host". This is a TRUE red: the driver itself also needs /// the route, so a green tick here would be a false positive. /// /// /// Handshake could not be ATTEMPTED on this host — the managed AMS router cannot run /// headless (Beckhoff's AdsClient.Connect throws a server exception /// "Check for a running TwinCAT router instance!"), or a /// / / / /// surfaces, or ReadStateAsync reports a client-side /// port-not-open status → DEGRADE: Ok=true, message /// "Reachable at {host}:{port} (ADS handshake unavailable on this host — TCP reachability /// only)", with latency. The probe NEVER produces a result worse than the old TCP-only /// probe. /// /// /// /// /// /// The line between case 2 (device-rejected → RED) and case 3 (can't-attempt → DEGRADE) is the /// crux. Classification rests on the exception's identity: only an /// (or a result ) carrying a route/target-port code is a RED — that is the /// ADS router answering and refusing the route. Beckhoff's TwinCAT.Ads.Server.AdsServerException /// ("running TwinCAT router instance!") derives from plain , NOT from /// , so it is correctly classified as "can't attempt → DEGRADE". /// When genuinely ambiguous, the probe DEGRADES (Ok=true, TCP-only note) rather than emit a false RED. /// /// AMS Net ID format is six dot-separated octets (e.g. 192.168.1.10.1.1); the first four /// are typically the host IPv4 address by Beckhoff convention. The AMS router resolves the real IP /// route server-side; the probe uses the first-four-octet heuristic for the TCP preflight target, /// which is reliable for the overwhelming majority of production deployments. /// /// The probe is read-only — ReadStateAsync never mutates PLC state — and always disposes the /// . /// public sealed class TwinCATDriverProbe : IDriverProbe { private static readonly JsonSerializerOptions _opts = new() { PropertyNameCaseInsensitive = true, UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, Converters = { new JsonStringEnumConverter() }, }; /// /// AMS error codes that mean "the router answered but refused the route / target port" — a /// genuine RED. The driver itself would also be denied, so a green tick would be a false /// positive. Everything NOT in this set (client-side port/connection errors, sync timeouts, /// router-not-initialised, etc.) is treated as "couldn't attempt the handshake" → DEGRADE. /// private static readonly HashSet _routeRejectCodes = [ AdsErrorCode.TargetPortNotFound, AdsErrorCode.TargetMachineNotFound, AdsErrorCode.PortNotConnected, AdsErrorCode.PortDisabled, AdsErrorCode.AccessDenied, AdsErrorCode.DeviceAccessDenied, ]; /// public string DriverType => "TwinCAT"; /// public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) { TwinCATDriverOptions? 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, parsed) = ExtractTarget(opts); if (parsed is null || string.IsNullOrWhiteSpace(host) || port <= 0) return new(false, "Config has no host/port to probe.", null); // Phase 1: bare TCP preflight — fast rejection for unreachable hosts. Messages here are // UNCHANGED from the original TCP-only probe. var sw = Stopwatch.StartNew(); 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) when (ct.IsCancellationRequested) { return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null); } catch (Exception ex) { return new(false, ex.Message, null); } // Phase 2: real ADS handshake. Connect + ReadStateAsync. The crux is the three-way // classification of how this can fail — see the class-doc and ClassifyHandshakeFailure. var degradeNote = $"Reachable at {host}:{port} (ADS handshake unavailable on this host — TCP reachability only)"; try { using var client = new AdsClient(); // Bound the ADS round-trip by the caller's timeout (clamped >=1s, mirrors AdsTwinCATClient). client.Timeout = (int)Math.Max(1_000, timeout.TotalMilliseconds); // Connect can throw a server exception ("running TwinCAT router instance!") on a headless // host with no AMS router — that is a can't-attempt DEGRADE, classified below. client.Connect(AmsNetId.Parse(parsed.NetId), parsed.Port); var state = await client.ReadStateAsync(ct).ConfigureAwait(false); sw.Stop(); if (state.Succeeded) return new(true, $"ADS state: {state.State.AdsState}", sw.Elapsed); // Non-throwing failure carried in the result's error code. return state.ErrorCode == AdsErrorCode.ClientPortNotOpen ? new(true, degradeNote, sw.Elapsed) // client never opened — DEGRADE : ClassifyHandshakeFailure(state.ErrorCode, host, port, sw, degradeNote); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { // Caller timeout — keep the original timed-out message. return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null); } catch (AdsErrorException ex) { // The router answered with an ADS-level error. Route/auth rejection → RED; anything // else (sync timeout, client port issues, …) → DEGRADE. return ClassifyHandshakeFailure(ex.ErrorCode, host, port, sw, degradeNote); } catch (Exception) { // Everything else — TwinCAT.Ads.Server.AdsServerException ("running TwinCAT router // instance!"), PlatformNotSupportedException, TypeInitializationException, // DllNotFoundException, NotSupportedException, etc. — means the handshake could not be // ATTEMPTED on this host. DEGRADE: never worse than the TCP-only probe. sw.Stop(); return new(true, degradeNote, sw.Elapsed); } } /// /// Classifies an ADS-level failure (from an or a non-success /// ReadStateAsync result). A route/target-port/access code means the router answered /// but refused the route → RED. Any other code is treated as "couldn't attempt" → DEGRADE, /// so the probe never under-reports a host with no usable ADS runtime. /// private static DriverProbeResult ClassifyHandshakeFailure( AdsErrorCode code, string host, int port, Stopwatch sw, string degradeNote) { if (_routeRejectCodes.Contains(code)) return new(false, $"Reachable at {host}:{port} but ADS handshake failed: {code} — check the target's ADS route table authorizes this host", null); sw.Stop(); return new(true, degradeNote, sw.Elapsed); } private static (string host, int port, TwinCATAmsAddress? parsed) ExtractTarget(TwinCATDriverOptions opts) { // Parse the first device's ads:// address. AMS Net ID is six-octet; by Beckhoff // convention the first four octets are the host IPv4. Extract those as the TCP target. var firstDevice = opts.Devices.FirstOrDefault(); if (firstDevice is null) return (string.Empty, 0, null); var parsed = TwinCATAmsAddress.TryParse(firstDevice.HostAddress); if (parsed is null) return (string.Empty, 0, null); // NetId = "a.b.c.d.e.f" — take the first 4 octets as the host IP. var parts = parsed.NetId.Split('.'); if (parts.Length < 4) return (string.Empty, 0, null); var hostIp = string.Join('.', parts[0], parts[1], parts[2], parts[3]); return (hostIp, parsed.Port, parsed); } }