From 8fbf167389f141b5aef0bbe27799a590fc169b15 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 12 May 2026 00:44:21 -0400 Subject: [PATCH] feat(commons): OpcUaEndpointConfigSerializer with legacy fallback + flat-dict interop --- .../OpcUaEndpointConfigSerializer.cs | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 src/ScadaLink.Commons/Serialization/OpcUaEndpointConfigSerializer.cs diff --git a/src/ScadaLink.Commons/Serialization/OpcUaEndpointConfigSerializer.cs b/src/ScadaLink.Commons/Serialization/OpcUaEndpointConfigSerializer.cs new file mode 100644 index 0000000..3dbc00a --- /dev/null +++ b/src/ScadaLink.Commons/Serialization/OpcUaEndpointConfigSerializer.cs @@ -0,0 +1,119 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using ScadaLink.Commons.Types.DataConnections; + +namespace ScadaLink.Commons.Serialization; + +/// +/// Serializes to/from the typed nested JSON +/// shape stored in DataConnection.PrimaryConfiguration / BackupConfiguration. +/// On read, falls back to the legacy flat string-dict shape for pre-refactor rows +/// and returns IsLegacy=true so the form can prompt the user to re-save. +/// +public static class OpcUaEndpointConfigSerializer +{ + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) } + }; + + public static string Serialize(OpcUaEndpointConfig config) + => JsonSerializer.Serialize(config, JsonOpts); + + public static (OpcUaEndpointConfig Config, bool IsLegacy) Deserialize(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return (new OpcUaEndpointConfig(), false); + + try + { + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.ValueKind == JsonValueKind.Object + && doc.RootElement.TryGetProperty("endpointUrl", out _)) + { + var typed = JsonSerializer.Deserialize(json, JsonOpts); + if (typed != null) + return (typed, false); + } + } + catch (JsonException) { /* fall through to legacy */ } + + return (LoadLegacy(json!), IsLegacy: true); + } + + /// + /// Flattens the typed config to the IDictionary<string,string> shape that + /// IDataConnection.ConnectAsync expects. Keys match the historical convention + /// used by OpcUaDataConnection so the adapter can keep that interface. + /// + public static IDictionary ToFlatDict(OpcUaEndpointConfig config) + { + var dict = new Dictionary + { + ["endpoint"] = config.EndpointUrl, + ["SecurityMode"] = config.SecurityMode.ToString(), + ["AutoAcceptUntrustedCerts"] = config.AutoAcceptUntrustedCerts.ToString(), + ["SessionTimeoutMs"] = config.SessionTimeoutMs.ToString(), + ["OperationTimeoutMs"] = config.OperationTimeoutMs.ToString(), + ["PublishingIntervalMs"] = config.PublishingIntervalMs.ToString(), + ["SamplingIntervalMs"] = config.SamplingIntervalMs.ToString(), + ["QueueSize"] = config.QueueSize.ToString(), + ["KeepAliveCount"] = config.KeepAliveCount.ToString(), + ["LifetimeCount"] = config.LifetimeCount.ToString(), + ["MaxNotificationsPerPublish"] = config.MaxNotificationsPerPublish.ToString(), + }; + if (config.Heartbeat is { } hb) + { + dict["HeartbeatTagPath"] = hb.TagPath; + dict["HeartbeatMaxSilence"] = hb.MaxSilenceSeconds.ToString(); + } + return dict; + } + + public static OpcUaEndpointConfig FromFlatDict(IDictionary dict) + { + var c = new OpcUaEndpointConfig + { + EndpointUrl = dict.TryGetValue("endpoint", out var ep) ? ep + : dict.TryGetValue("EndpointUrl", out var ep2) ? ep2 : "", + SecurityMode = Enum.TryParse( + dict.TryGetValue("SecurityMode", out var smStr) ? smStr : null, + ignoreCase: true, out var sm) + ? sm : OpcUaSecurityMode.None, + AutoAcceptUntrustedCerts = ParseBool(dict, "AutoAcceptUntrustedCerts", true), + SessionTimeoutMs = ParseInt(dict, "SessionTimeoutMs", 60000), + OperationTimeoutMs = ParseInt(dict, "OperationTimeoutMs", 15000), + PublishingIntervalMs = ParseInt(dict, "PublishingIntervalMs", 1000), + SamplingIntervalMs = ParseInt(dict, "SamplingIntervalMs", 1000), + QueueSize = ParseInt(dict, "QueueSize", 10), + KeepAliveCount = ParseInt(dict, "KeepAliveCount", 10), + LifetimeCount = ParseInt(dict, "LifetimeCount", 30), + MaxNotificationsPerPublish = ParseInt(dict, "MaxNotificationsPerPublish", 100), + }; + if (dict.TryGetValue("HeartbeatTagPath", out var hbPath) + && !string.IsNullOrWhiteSpace(hbPath)) + { + c.Heartbeat = new OpcUaHeartbeatConfig + { + TagPath = hbPath, + MaxSilenceSeconds = ParseInt(dict, "HeartbeatMaxSilence", 30) + }; + } + return c; + } + + private static OpcUaEndpointConfig LoadLegacy(string json) + { + var dict = JsonSerializer.Deserialize>(json) + ?? new Dictionary(); + return FromFlatDict(dict); + } + + private static int ParseInt(IDictionary d, string key, int defaultValue) + => d.TryGetValue(key, out var s) && int.TryParse(s, out var v) ? v : defaultValue; + + private static bool ParseBool(IDictionary d, string key, bool defaultValue) + => d.TryGetValue(key, out var s) && bool.TryParse(s, out var v) ? v : defaultValue; +}