diff --git a/src/ScadaLink.Commons/Validators/OpcUaEndpointConfigValidator.cs b/src/ScadaLink.Commons/Validators/OpcUaEndpointConfigValidator.cs
new file mode 100644
index 0000000..d59777e
--- /dev/null
+++ b/src/ScadaLink.Commons/Validators/OpcUaEndpointConfigValidator.cs
@@ -0,0 +1,62 @@
+using ScadaLink.Commons.Types.DataConnections;
+using ScadaLink.Commons.Types.Flattening;
+
+namespace ScadaLink.Commons.Validators;
+
+///
+/// Pure-function validator for . Errors carry
+/// the offending property name in
+/// (optionally prefixed, e.g. "Primary.EndpointUrl") so the form can render
+/// per-field messages.
+///
+public static class OpcUaEndpointConfigValidator
+{
+ public static ValidationResult Validate(OpcUaEndpointConfig config, string fieldPrefix = "")
+ {
+ var errors = new List();
+
+ if (string.IsNullOrWhiteSpace(config.EndpointUrl))
+ errors.Add(Err("EndpointUrl", "Endpoint URL is required."));
+ else if (!Uri.TryCreate(config.EndpointUrl, UriKind.Absolute, out var uri)
+ || uri.Scheme != "opc.tcp"
+ || string.IsNullOrEmpty(uri.Host))
+ errors.Add(Err("EndpointUrl", "Endpoint URL must be a valid opc.tcp:// URI."));
+
+ if (config.SessionTimeoutMs <= 0)
+ errors.Add(Err("SessionTimeoutMs", "Must be > 0."));
+ if (config.OperationTimeoutMs <= 0)
+ errors.Add(Err("OperationTimeoutMs", "Must be > 0."));
+ if (config.PublishingIntervalMs <= 0)
+ errors.Add(Err("PublishingIntervalMs", "Must be > 0."));
+ if (config.SamplingIntervalMs <= 0)
+ errors.Add(Err("SamplingIntervalMs", "Must be > 0."));
+ if (config.QueueSize < 1)
+ errors.Add(Err("QueueSize", "Must be ≥ 1."));
+ if (config.KeepAliveCount < 1)
+ errors.Add(Err("KeepAliveCount", "Must be ≥ 1."));
+ if (config.LifetimeCount < config.KeepAliveCount * 3)
+ errors.Add(Err("LifetimeCount",
+ "Must be at least 3× KeepAliveCount per OPC UA spec."));
+ if (config.MaxNotificationsPerPublish < 1)
+ errors.Add(Err("MaxNotificationsPerPublish", "Must be ≥ 1."));
+
+ if (config.Heartbeat is { } hb)
+ {
+ if (string.IsNullOrWhiteSpace(hb.TagPath))
+ errors.Add(Err("Heartbeat.TagPath",
+ "Tag path is required when heartbeat is enabled."));
+ if (hb.MaxSilenceSeconds <= 0)
+ errors.Add(Err("Heartbeat.MaxSilenceSeconds", "Must be > 0."));
+ }
+
+ return errors.Count == 0
+ ? ValidationResult.Success()
+ : ValidationResult.FromErrors(errors.ToArray());
+
+ ValidationEntry Err(string field, string message) =>
+ ValidationEntry.Error(
+ ValidationCategory.ConnectionConfig,
+ message,
+ entityName: $"{fieldPrefix}{field}");
+ }
+}