using System.Text.Json; using System.Text.Json.Serialization; using ScadaLink.Commons.Types.DataConnections; namespace ScadaLink.Commons.Serialization; /// /// Outcome classification for . /// public enum OpcUaConfigParseStatus { /// The stored JSON parsed cleanly as the current typed shape. Typed, /// /// The stored JSON parsed as the legacy flat string-dict shape. The returned /// config is usable; the caller may prompt the user to re-save in the new shape. /// Legacy, /// /// The stored JSON could not be parsed at all (genuinely malformed). The returned /// config is an empty default and the original string was lost — the caller should /// surface an error rather than presenting the empty config as the user's data. /// Malformed } /// /// Result of . Carries the parsed /// config plus an explicit distinguishing a recoverable legacy row /// from genuinely unparseable input. Deconstructs into (Config, IsLegacy) for /// backward compatibility with callers that only need those two values. /// public readonly record struct OpcUaConfigParseResult { public OpcUaConfigParseResult(OpcUaEndpointConfig config, OpcUaConfigParseStatus status) { Config = config; Status = status; } /// The parsed config (an empty default when is Malformed). public OpcUaEndpointConfig Config { get; } /// Classification of the parse outcome. public OpcUaConfigParseStatus Status { get; } /// True when the source parsed as the legacy flat-dict shape. public bool IsLegacy => Status == OpcUaConfigParseStatus.Legacy; /// True when the source could not be parsed at all. public bool IsMalformed => Status == OpcUaConfigParseStatus.Malformed; /// /// Two-element deconstruction kept for backward compatibility. Note that /// IsLegacy is false for both /// and ; callers that need to tell those /// apart should read directly. /// public void Deconstruct(out OpcUaEndpointConfig config, out bool isLegacy) { config = Config; isLegacy = IsLegacy; } } /// /// Serializes to/from the typed nested JSON /// shape stored in DataConnection.PrimaryConfiguration / BackupConfiguration. /// On read, falls back to the legacy flat string-dict shape only for rows that are not /// the current typed shape (no endpointUrl property), reporting /// so the form can prompt the user to /// re-save. A row that is the typed shape but fails to deserialize is reported /// , never . /// 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); /// /// Parses stored OPC UA endpoint JSON. Tries the current typed shape first, then the /// legacy flat string-dict shape. The returned /// distinguishes three outcomes: /// /// — clean parse of the current shape /// (also returned for null/blank input, which yields a default config). /// — parsed as a legacy flat object; /// the config is usable and the caller may prompt a re-save. /// — the input is genuinely /// unparseable JSON, or it is the current typed shape (it has an /// endpointUrl property) but typed deserialization failed — e.g. an /// enum-valued field holding an unrecognised string or a wrong-typed field. Such a /// corrupt typed row is reported /// rather than being mislabelled , so the /// offending field is not silently dropped. The config is an empty default and the /// caller should surface an error rather than treating it as the user's saved data. /// /// public static OpcUaConfigParseResult Deserialize(string? json) { if (string.IsNullOrWhiteSpace(json)) return new OpcUaConfigParseResult(new OpcUaEndpointConfig(), OpcUaConfigParseStatus.Typed); // First decide which shape the row is — without yet trying to materialize it. // A root JSON object carrying "endpointUrl" IS the current typed shape; anything // else (no endpointUrl) is treated as a candidate legacy flat-dict row. bool isTypedShape; try { using var doc = JsonDocument.Parse(json); isTypedShape = doc.RootElement.ValueKind == JsonValueKind.Object && doc.RootElement.TryGetProperty("endpointUrl", out _); } catch (JsonException) { // Could not even parse the document: genuinely malformed input. return new OpcUaConfigParseResult(new OpcUaEndpointConfig(), OpcUaConfigParseStatus.Malformed); } if (isTypedShape) { // The row is the current typed shape. If typed deserialization fails the row // is a corrupt current-shape row (e.g. an invalid enum or wrong-typed field) — // it must NOT fall through to the legacy path and be mislabelled Legacy, which // would silently drop the offending field. Report Malformed instead. try { var typed = JsonSerializer.Deserialize(json, JsonOpts); if (typed != null) return new OpcUaConfigParseResult(typed, OpcUaConfigParseStatus.Typed); } catch (JsonException) { /* corrupt typed row — classified Malformed below */ } return new OpcUaConfigParseResult(new OpcUaEndpointConfig(), OpcUaConfigParseStatus.Malformed); } try { return new OpcUaConfigParseResult(LoadLegacy(json), OpcUaConfigParseStatus.Legacy); } catch (JsonException) { // Genuinely malformed input: not a recoverable legacy row. Report Malformed // (not Legacy) so the caller can surface an error instead of presenting an // empty config as if it were the user's saved configuration. return new OpcUaConfigParseResult(new OpcUaEndpointConfig(), OpcUaConfigParseStatus.Malformed); } } /// /// 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(), ["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; } public static OpcUaEndpointConfig FromFlatDict(IDictionary dict) { var c = new OpcUaEndpointConfig(); if (dict.TryGetValue("endpoint", out var ep) && !string.IsNullOrWhiteSpace(ep)) c.EndpointUrl = ep; else if (dict.TryGetValue("EndpointUrl", out var ep2) && !string.IsNullOrWhiteSpace(ep2)) c.EndpointUrl = ep2; if (dict.TryGetValue("SecurityMode", out var smStr) && Enum.TryParse(smStr, ignoreCase: true, out var sm)) c.SecurityMode = sm; if (dict.TryGetValue("AutoAcceptUntrustedCerts", out var aacStr) && bool.TryParse(aacStr, out var aac)) c.AutoAcceptUntrustedCerts = aac; TryAssignInt(dict, "SessionTimeoutMs", v => c.SessionTimeoutMs = v); TryAssignInt(dict, "OperationTimeoutMs", v => c.OperationTimeoutMs = v); TryAssignInt(dict, "PublishingIntervalMs", v => c.PublishingIntervalMs = v); TryAssignInt(dict, "SamplingIntervalMs", v => c.SamplingIntervalMs = v); TryAssignInt(dict, "QueueSize", v => c.QueueSize = v); TryAssignInt(dict, "KeepAliveCount", v => c.KeepAliveCount = v); 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)) { var hb = new OpcUaHeartbeatConfig { TagPath = hbPath }; TryAssignInt(dict, "HeartbeatMaxSilence", v => hb.MaxSilenceSeconds = v); 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; } private static OpcUaEndpointConfig LoadLegacy(string json) { using var doc = JsonDocument.Parse(json); if (doc.RootElement.ValueKind != JsonValueKind.Object) throw new JsonException("Legacy JSON must be a flat object."); var dict = new Dictionary(); foreach (var prop in doc.RootElement.EnumerateObject()) { // JsonElement.ToString() returns the raw value for primitives: // "1000" for numbers, "true"/"false" for booleans, the string content // for strings. Nested objects/arrays serialize to their JSON; we don't // expect those in the legacy flat-dict shape, but FromFlatDict will // simply ignore unknown keys. dict[prop.Name] = prop.Value.ValueKind switch { JsonValueKind.String => prop.Value.GetString() ?? "", JsonValueKind.Number => prop.Value.GetRawText(), JsonValueKind.True => "True", JsonValueKind.False => "False", _ => prop.Value.ToString() }; } return FromFlatDict(dict); } private static void TryAssignInt(IDictionary dict, string key, Action assign) { if (dict.TryGetValue(key, out var s) && int.TryParse(s, out var v)) assign(v); } }