From b60a8ef4099ce0d6a010d79d0a053d4739052297 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 12 May 2026 02:22:51 -0400 Subject: [PATCH] feat(commons): Layer B serializer + validator handle new OPC UA settings OpcUaEndpointConfigSerializer: - ToFlatDict emits new scalar keys (DiscardOldest, SubscriptionPriority, SubscriptionDisplayName, TimestampsToReturn). - ToFlatDict emits dotted sub-object keys (UserIdentity.TokenType / Username / Password / CertificatePath / CertificatePassword, Deadband.Type / Value) when those sub-objects are non-null. - FromFlatDict reads the same keys back; missing keys preserve POCO defaults. - Deadband.Value uses InvariantCulture for double parsing/formatting. OpcUaEndpointConfigValidator: - SubscriptionDisplayName required (non-empty). - UserIdentity.UsernamePassword requires Username. - UserIdentity.X509Certificate requires CertificatePath. - Deadband.Value must be > 0 when Deadband is set. - fieldPrefix propagates through sub-object error EntityNames. Drives the 11 previously-failing tests green; 51/51 in the suite now pass. --- .../OpcUaEndpointConfigSerializer.cs | 49 +++++++++++++++++++ .../OpcUaEndpointConfigValidator.cs | 19 +++++++ 2 files changed, 68 insertions(+) diff --git a/src/ScadaLink.Commons/Serialization/OpcUaEndpointConfigSerializer.cs b/src/ScadaLink.Commons/Serialization/OpcUaEndpointConfigSerializer.cs index 60b305d..d281bd1 100644 --- a/src/ScadaLink.Commons/Serialization/OpcUaEndpointConfigSerializer.cs +++ b/src/ScadaLink.Commons/Serialization/OpcUaEndpointConfigSerializer.cs @@ -70,12 +70,29 @@ public static class OpcUaEndpointConfigSerializer ["KeepAliveCount"] = config.KeepAliveCount.ToString(), ["LifetimeCount"] = config.LifetimeCount.ToString(), ["MaxNotificationsPerPublish"] = config.MaxNotificationsPerPublish.ToString(), + ["DiscardOldest"] = config.DiscardOldest.ToString(), + ["SubscriptionPriority"] = config.SubscriptionPriority.ToString(), + ["SubscriptionDisplayName"] = config.SubscriptionDisplayName, + ["TimestampsToReturn"] = config.TimestampsToReturn.ToString(), }; if (config.Heartbeat is { } hb) { dict["HeartbeatTagPath"] = hb.TagPath; dict["HeartbeatMaxSilence"] = hb.MaxSilenceSeconds.ToString(); } + if (config.UserIdentity is { } ui) + { + dict["UserIdentity.TokenType"] = ui.TokenType.ToString(); + dict["UserIdentity.Username"] = ui.Username; + dict["UserIdentity.Password"] = ui.Password; + dict["UserIdentity.CertificatePath"] = ui.CertificatePath; + dict["UserIdentity.CertificatePassword"] = ui.CertificatePassword; + } + if (config.Deadband is { } db) + { + dict["Deadband.Type"] = db.Type.ToString(); + dict["Deadband.Value"] = db.Value.ToString(System.Globalization.CultureInfo.InvariantCulture); + } return dict; } @@ -105,6 +122,16 @@ public static class OpcUaEndpointConfigSerializer TryAssignInt(dict, "LifetimeCount", v => c.LifetimeCount = v); TryAssignInt(dict, "MaxNotificationsPerPublish", v => c.MaxNotificationsPerPublish = v); + if (dict.TryGetValue("DiscardOldest", out var doStr) && bool.TryParse(doStr, out var doVal)) + c.DiscardOldest = doVal; + if (dict.TryGetValue("SubscriptionPriority", out var spStr) && byte.TryParse(spStr, out var spVal)) + c.SubscriptionPriority = spVal; + if (dict.TryGetValue("SubscriptionDisplayName", out var sdnStr) && !string.IsNullOrWhiteSpace(sdnStr)) + c.SubscriptionDisplayName = sdnStr; + if (dict.TryGetValue("TimestampsToReturn", out var ttrStr) + && Enum.TryParse(ttrStr, ignoreCase: true, out var ttr)) + c.TimestampsToReturn = ttr; + if (dict.TryGetValue("HeartbeatTagPath", out var hbPath) && !string.IsNullOrWhiteSpace(hbPath)) { @@ -113,6 +140,28 @@ public static class OpcUaEndpointConfigSerializer c.Heartbeat = hb; } + if (dict.TryGetValue("UserIdentity.TokenType", out var uiTt) + && Enum.TryParse(uiTt, ignoreCase: true, out var tokenType)) + { + var ui = new OpcUaUserIdentityConfig { TokenType = tokenType }; + if (dict.TryGetValue("UserIdentity.Username", out var u)) ui.Username = u; + if (dict.TryGetValue("UserIdentity.Password", out var p)) ui.Password = p; + if (dict.TryGetValue("UserIdentity.CertificatePath", out var cp)) ui.CertificatePath = cp; + if (dict.TryGetValue("UserIdentity.CertificatePassword", out var cpw)) ui.CertificatePassword = cpw; + c.UserIdentity = ui; + } + + if (dict.TryGetValue("Deadband.Type", out var dbT) + && Enum.TryParse(dbT, ignoreCase: true, out var dbType)) + { + var db = new OpcUaDeadbandConfig { Type = dbType }; + if (dict.TryGetValue("Deadband.Value", out var dbV) + && double.TryParse(dbV, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var dbVal)) + db.Value = dbVal; + c.Deadband = db; + } + return c; } diff --git a/src/ScadaLink.Commons/Validators/OpcUaEndpointConfigValidator.cs b/src/ScadaLink.Commons/Validators/OpcUaEndpointConfigValidator.cs index d59777e..fc0e494 100644 --- a/src/ScadaLink.Commons/Validators/OpcUaEndpointConfigValidator.cs +++ b/src/ScadaLink.Commons/Validators/OpcUaEndpointConfigValidator.cs @@ -40,6 +40,10 @@ public static class OpcUaEndpointConfigValidator if (config.MaxNotificationsPerPublish < 1) errors.Add(Err("MaxNotificationsPerPublish", "Must be ≥ 1.")); + if (string.IsNullOrWhiteSpace(config.SubscriptionDisplayName)) + errors.Add(Err("SubscriptionDisplayName", + "Subscription display name is required.")); + if (config.Heartbeat is { } hb) { if (string.IsNullOrWhiteSpace(hb.TagPath)) @@ -49,6 +53,21 @@ public static class OpcUaEndpointConfigValidator errors.Add(Err("Heartbeat.MaxSilenceSeconds", "Must be > 0.")); } + if (config.UserIdentity is { } ui) + { + if (ui.TokenType == OpcUaUserTokenType.UsernamePassword + && string.IsNullOrWhiteSpace(ui.Username)) + errors.Add(Err("UserIdentity.Username", + "Username is required when token type is UsernamePassword.")); + if (ui.TokenType == OpcUaUserTokenType.X509Certificate + && string.IsNullOrWhiteSpace(ui.CertificatePath)) + errors.Add(Err("UserIdentity.CertificatePath", + "Certificate path is required when token type is X509Certificate.")); + } + + if (config.Deadband is { } db && db.Value <= 0) + errors.Add(Err("Deadband.Value", "Must be > 0.")); + return errors.Count == 0 ? ValidationResult.Success() : ValidationResult.FromErrors(errors.ToArray());