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;
+}