diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs new file mode 100644 index 00000000..754d9b9f --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs @@ -0,0 +1,72 @@ +using System.Diagnostics; +using System.Net.Sockets; +using System.Text.Json; +using System.Text.Json.Serialization; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +/// +/// Cheap TCP-connect probe for the -shaped driver config. +/// Opens a socket to the first device's gateway host + EtherNet/IP port and closes +/// immediately. Surfaces a green tick + latency on success; red chip + SocketError on +/// failure; "timed out" on the caller's cancellation. Does NOT exchange any CIP bytes — +/// a richer EIP session-open probe is a documented follow-up. +/// +public sealed class AbCipDriverProbe : IDriverProbe +{ + private static readonly JsonSerializerOptions _opts = new() + { + PropertyNameCaseInsensitive = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + }; + + /// + 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 (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(); + try + { + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await socket.ConnectAsync(host, port, ct); + sw.Stop(); + return new(true, null, sw.Elapsed); + } + 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); + } + } + + private static (string host, int port) 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 (string.Empty, 0); + + var parsed = AbCipHostAddress.TryParse(firstDevice.HostAddress); + if (parsed is null) return (string.Empty, 0); + + return (parsed.Gateway, parsed.Port); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs new file mode 100644 index 00000000..3a178788 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs @@ -0,0 +1,72 @@ +using System.Diagnostics; +using System.Net.Sockets; +using System.Text.Json; +using System.Text.Json.Serialization; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; + +/// +/// Cheap TCP-connect probe for the -shaped driver config. +/// Opens a socket to the first device's gateway host + EtherNet/IP port and closes +/// immediately. Surfaces a green tick + latency on success; red chip + SocketError on +/// failure; "timed out" on the caller's cancellation. Does NOT exchange any PCCC bytes — +/// a richer EIP session-open probe is a documented follow-up. +/// +public sealed class AbLegacyDriverProbe : IDriverProbe +{ + private static readonly JsonSerializerOptions _opts = new() + { + PropertyNameCaseInsensitive = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + }; + + /// + 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 (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(); + try + { + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await socket.ConnectAsync(host, port, ct); + sw.Stop(); + return new(true, null, sw.Elapsed); + } + 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); + } + } + + private static (string host, int port) 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 (string.Empty, 0); + + var parsed = AbLegacyHostAddress.TryParse(firstDevice.HostAddress); + if (parsed is null) return (string.Empty, 0); + + return (parsed.Gateway, parsed.Port); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverProbe.cs new file mode 100644 index 00000000..e7aa2b01 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverProbe.cs @@ -0,0 +1,72 @@ +using System.Diagnostics; +using System.Net.Sockets; +using System.Text.Json; +using System.Text.Json.Serialization; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +/// +/// Cheap TCP-connect probe for the -shaped driver config. +/// Opens a socket to the first device's FOCAS Ethernet address + port and closes +/// immediately. Surfaces a green tick + latency on success; red chip + SocketError on +/// failure; "timed out" on the caller's cancellation. Does NOT exchange any FOCAS/2 bytes — +/// a richer FOCAS handshake (cnc_allclibhndl3) probe is a documented follow-up. +/// +public sealed class FocasDriverProbe : IDriverProbe +{ + private static readonly JsonSerializerOptions _opts = new() + { + PropertyNameCaseInsensitive = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + }; + + /// + 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(); + try + { + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await socket.ConnectAsync(host, port, ct); + sw.Stop(); + return new(true, null, sw.Elapsed); + } + 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); + } + } + + 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); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverProbe.cs new file mode 100644 index 00000000..202259a4 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverProbe.cs @@ -0,0 +1,85 @@ +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.Galaxy.Config; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy; + +/// +/// Cheap TCP-connect probe for the -shaped driver config. +/// Parses the Gateway.Endpoint gRPC endpoint (e.g. http://host:5001 or +/// host:5001), opens a socket and closes immediately. Surfaces a green tick + +/// latency on success; red chip + SocketError on failure; "timed out" on the caller's +/// cancellation. Does NOT exchange any gRPC frames — a richer gRPC ping probe is a +/// documented follow-up. +/// +public sealed class GalaxyDriverProbe : IDriverProbe +{ + private static readonly JsonSerializerOptions _opts = new() + { + PropertyNameCaseInsensitive = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + }; + + /// + public string DriverType => GalaxyDriverFactoryExtensions.DriverTypeName; // "GalaxyMxGateway" + + /// + public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) + { + GalaxyDriverOptions? 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(); + try + { + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await socket.ConnectAsync(host, port, ct); + sw.Stop(); + return new(true, null, sw.Elapsed); + } + 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); + } + } + + private static (string host, int port) ExtractTarget(GalaxyDriverOptions opts) + { + var endpoint = opts.Gateway.Endpoint; + if (string.IsNullOrWhiteSpace(endpoint)) return (string.Empty, 0); + + // Try absolute URI first (e.g. "http://hostname:5001" or "https://hostname:5001"). + if (Uri.TryCreate(endpoint, UriKind.Absolute, out var uri)) + { + var host = uri.Host; + // Uri.Port is -1 when not specified; default mxaccessgw port is 5001. + var port = uri.Port > 0 ? uri.Port : 5001; + return (host, port); + } + + // Fallback: treat as "host:port" (no scheme). + var colonIdx = endpoint.LastIndexOf(':'); + if (colonIdx > 0 && int.TryParse(endpoint[(colonIdx + 1)..], out var rawPort) && rawPort > 0) + return (endpoint[..colonIdx], rawPort); + + // No port found — return the whole string as host with default port. + return (endpoint, 5001); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianDriverProbe.cs new file mode 100644 index 00000000..633c228c --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianDriverProbe.cs @@ -0,0 +1,47 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client; + +/// +/// Driver probe for the -shaped driver config. +/// The Wonderware Historian client communicates over a Windows named pipe (not a TCP socket), +/// so a cheap TCP-connect probe is not applicable for this transport. This probe always +/// returns a well-formed "not applicable" result so the AdminUI can display a meaningful +/// message instead of a red error. A full named-pipe connect + Hello-frame probe is a +/// documented follow-up. +/// +public sealed class WonderwareHistorianDriverProbe : IDriverProbe +{ + private static readonly JsonSerializerOptions _opts = new() + { + PropertyNameCaseInsensitive = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + }; + + /// + public string DriverType => "Historian.Wonderware"; + + /// + public Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) + { + // Validate the config JSON can at least be parsed — surface bad JSON immediately. + WonderwareHistorianClientOptions? opts; + try { opts = JsonSerializer.Deserialize(configJson, _opts); } + catch (Exception ex) + { + return Task.FromResult(new DriverProbeResult(false, $"Config JSON is invalid: {ex.Message}", null)); + } + if (opts is null) + return Task.FromResult(new DriverProbeResult(false, "Config JSON deserialized to null.", null)); + + // The Wonderware Historian sidecar communicates over a Windows named pipe; there is no + // TCP endpoint to connect to. A full pipe connect + Hello-frame probe is a follow-up. + return Task.FromResult(new DriverProbeResult( + false, + "TCP probe not applicable for this transport — the Historian sidecar uses a named pipe. " + + "A full named-pipe probe is a documented follow-up.", + null)); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs new file mode 100644 index 00000000..f707ce22 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs @@ -0,0 +1,63 @@ +using System.Diagnostics; +using System.Net.Sockets; +using System.Text.Json; +using System.Text.Json.Serialization; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus; + +/// +/// Cheap TCP-connect probe for the -shaped driver config. +/// Opens a socket to the configured endpoint and closes immediately. Surfaces a green +/// tick + latency on success; red chip + SocketError on failure; "timed out" on the +/// caller's cancellation. Does NOT exchange any protocol bytes — richer per-driver +/// handshakes are a documented follow-up. +/// +public sealed class ModbusDriverProbe : IDriverProbe +{ + private static readonly JsonSerializerOptions _opts = new() + { + PropertyNameCaseInsensitive = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + }; + + /// + public string DriverType => "Modbus"; + + /// + public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) + { + ModbusDriverOptions? 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(); + try + { + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await socket.ConnectAsync(host, port, ct); + sw.Stop(); + return new(true, null, sw.Elapsed); + } + 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); + } + } + + private static (string host, int port) ExtractTarget(ModbusDriverOptions opts) + => (opts.Host, opts.Port); +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverProbe.cs new file mode 100644 index 00000000..c5f82b2f --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverProbe.cs @@ -0,0 +1,78 @@ +using System.Diagnostics; +using System.Net.Sockets; +using System.Text.Json; +using System.Text.Json.Serialization; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; + +/// +/// Cheap TCP-connect probe for the -shaped driver config. +/// Parses the first endpoint URL (from or +/// the convenience fallback), opens a +/// socket to the OPC UA server host + port and closes immediately. Surfaces a green tick + +/// latency on success; red chip + SocketError on failure; "timed out" on the caller's +/// cancellation. Does NOT open an OPC UA session — a richer session-open probe is a +/// documented follow-up. +/// +public sealed class OpcUaClientDriverProbe : IDriverProbe +{ + private static readonly JsonSerializerOptions _opts = new() + { + PropertyNameCaseInsensitive = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + }; + + /// + public string DriverType => "OpcUaClient"; + + /// + public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) + { + OpcUaClientDriverOptions? 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(); + try + { + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await socket.ConnectAsync(host, port, ct); + sw.Stop(); + return new(true, null, sw.Elapsed); + } + 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); + } + } + + private static (string host, int port) ExtractTarget(OpcUaClientDriverOptions opts) + { + // EndpointUrls wins over the convenience EndpointUrl when both are set. + var endpointUrl = opts.EndpointUrls.FirstOrDefault() + ?? (string.IsNullOrWhiteSpace(opts.EndpointUrl) ? null : opts.EndpointUrl); + if (endpointUrl is null) return (string.Empty, 0); + + // Parse as a URI — opc.tcp://host:port is a valid URI. + if (!Uri.TryCreate(endpointUrl, UriKind.Absolute, out var uri)) + return (string.Empty, 0); + + var host = uri.Host; + var port = uri.IsDefaultPort ? 4840 : uri.Port; + return (host, port); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs new file mode 100644 index 00000000..c2909f1b --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs @@ -0,0 +1,63 @@ +using System.Diagnostics; +using System.Net.Sockets; +using System.Text.Json; +using System.Text.Json.Serialization; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7; + +/// +/// Cheap TCP-connect probe for the -shaped driver config. +/// Opens a socket to the configured host + ISO-on-TCP port 102 and closes immediately. +/// Surfaces a green tick + latency on success; red chip + SocketError on failure; "timed +/// out" on the caller's cancellation. Does NOT exchange any S7comm bytes — a richer +/// ISO-on-TCP connection probe is a documented follow-up. +/// +public sealed class S7DriverProbe : IDriverProbe +{ + private static readonly JsonSerializerOptions _opts = new() + { + PropertyNameCaseInsensitive = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + }; + + /// + public string DriverType => "S7"; + + /// + public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) + { + S7DriverOptions? 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(); + try + { + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await socket.ConnectAsync(host, port, ct); + sw.Stop(); + return new(true, null, sw.Elapsed); + } + 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); + } + } + + private static (string host, int port) ExtractTarget(S7DriverOptions opts) + => (opts.Host, opts.Port); +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverProbe.cs new file mode 100644 index 00000000..e77d4dc2 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverProbe.cs @@ -0,0 +1,84 @@ +using System.Diagnostics; +using System.Net.Sockets; +using System.Text.Json; +using System.Text.Json.Serialization; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; + +/// +/// Cheap TCP-connect probe for the -shaped driver config. +/// Opens a socket to the first device's AMS router host (first four octets of the AMS Net ID) +/// on the AMS port from the address and closes immediately. Surfaces a green tick + latency +/// on success; red chip + SocketError on failure; "timed out" on the caller's cancellation. +/// Does NOT exchange any ADS bytes — a richer ADS-state probe is a documented follow-up. +/// +/// +/// 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, but the AMS router +/// resolves the real IP route server-side. The probe uses the first-four-octet heuristic +/// which is reliable for the overwhelming majority of production deployments. +/// +public sealed class TwinCATDriverProbe : IDriverProbe +{ + private static readonly JsonSerializerOptions _opts = new() + { + PropertyNameCaseInsensitive = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + }; + + /// + 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) = ExtractTarget(opts); + if (string.IsNullOrWhiteSpace(host) || port <= 0) + return new(false, "Config has no host/port to probe.", null); + + var sw = Stopwatch.StartNew(); + try + { + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await socket.ConnectAsync(host, port, ct); + sw.Stop(); + return new(true, null, sw.Elapsed); + } + 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); + } + } + + private static (string host, int port) 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); + + var parsed = TwinCATAmsAddress.TryParse(firstDevice.HostAddress); + if (parsed is null) return (string.Empty, 0); + + // 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); + + var hostIp = string.Join('.', parts[0], parts[1], parts[2], parts[3]); + return (hostIp, parsed.Port); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs index fbaf7e4f..845e0232 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs @@ -5,6 +5,17 @@ using ZB.MOM.WW.OtOpcUa.Core.Hosting; namespace ZB.MOM.WW.OtOpcUa.Host.Drivers; +// Probe type aliases — keep the using list concise. +using ModbusProbe = Driver.Modbus.ModbusDriverProbe; +using AbCipProbe = Driver.AbCip.AbCipDriverProbe; +using AbLegacyProbe = Driver.AbLegacy.AbLegacyDriverProbe; +using S7Probe = Driver.S7.S7DriverProbe; +using TwinCATProbe = Driver.TwinCAT.TwinCATDriverProbe; +using FocasProbe = Driver.FOCAS.FocasDriverProbe; +using OpcUaProbe = Driver.OpcUaClient.OpcUaClientDriverProbe; +using GalaxyProbe = Driver.Galaxy.GalaxyDriverProbe; +using HistorianProbe = Driver.Historian.Wonderware.Client.WonderwareHistorianDriverProbe; + /// /// Wires every cross-platform driver assembly's Register(registry, loggerFactory) /// extension into a single singleton and binds the @@ -34,6 +45,18 @@ public static class DriverFactoryBootstrap }); services.AddSingleton(sp => new DriverFactoryRegistryAdapter(sp.GetRequiredService())); + + // One IDriverProbe per driver type — wired into AdminOperationsActor via DI enumeration. + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj index bee41156..c0296284 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj @@ -55,7 +55,9 @@ + +