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 @@
+
+