using System.Globalization; using System.IO; using System.Text; using System.Text.Json; using ScadaLink.Commons.Types.Enums; namespace ScadaLink.CentralUI.Components.Shared; /// /// Round-trip codec for the alarm trigger configuration JSON used by both /// (UI editing) and AlarmActor (runtime /// evaluation). The serialized shape per trigger type: /// ValueMatch { attributeName, matchValue } ("!=X" prefix = not equals) /// RangeViolation { attributeName, min, max } /// RateOfChange { attributeName, thresholdPerSecond, windowSeconds, direction } /// HiLo { attributeName, loLo, lo, hi, hiHi, /// loLoPriority, loPriority, hiPriority, hiHiPriority } /// Expression { expression } /// /// All HiLo setpoints and per-setpoint priorities are optional — any subset /// is valid (e.g., only Hi/HiHi configured for over-temperature protection). /// /// Parsing also accepts legacy aliases the runtime used to consume /// (attribute, value, low, high) so older configs /// survive a round-trip through the editor. /// internal static class AlarmTriggerConfigCodec { /// /// Parses a trigger configuration JSON in the context of the given trigger /// type. Returns a model with default values on null/empty/malformed input /// or for missing keys — never throws. /// /// The trigger configuration JSON string, or null/empty for defaults. /// The alarm trigger type that determines which properties to extract. /// A populated AlarmTriggerModel with default values for missing fields. internal static AlarmTriggerModel Parse(string? json, AlarmTriggerType type) { var model = new AlarmTriggerModel(); if (string.IsNullOrWhiteSpace(json)) return model; try { using var doc = JsonDocument.Parse(json); var root = doc.RootElement; model.AttributeName = root.TryGetProperty("attributeName", out var a) ? a.GetString() : root.TryGetProperty("attribute", out var a2) ? a2.GetString() : null; switch (type) { case AlarmTriggerType.ValueMatch: { var raw = root.TryGetProperty("matchValue", out var mv) ? mv.GetString() : root.TryGetProperty("value", out var mv2) ? mv2.GetString() : null; if (raw != null && raw.StartsWith("!=", StringComparison.Ordinal)) { model.NotEquals = true; model.MatchValue = raw[2..]; } else { model.MatchValue = raw; } break; } case AlarmTriggerType.RangeViolation: model.Min = TryReadDouble(root, "min") ?? TryReadDouble(root, "low"); model.Max = TryReadDouble(root, "max") ?? TryReadDouble(root, "high"); break; case AlarmTriggerType.RateOfChange: model.ThresholdPerSecond = TryReadDouble(root, "thresholdPerSecond"); model.WindowSeconds = TryReadDouble(root, "windowSeconds"); var dir = root.TryGetProperty("direction", out var d) ? d.GetString() : null; model.Direction = NormalizeDirection(dir); break; case AlarmTriggerType.HiLo: model.LoLo = TryReadDouble(root, "loLo"); model.Lo = TryReadDouble(root, "lo"); model.Hi = TryReadDouble(root, "hi"); model.HiHi = TryReadDouble(root, "hiHi"); model.LoLoPriority = TryReadInt(root, "loLoPriority"); model.LoPriority = TryReadInt(root, "loPriority"); model.HiPriority = TryReadInt(root, "hiPriority"); model.HiHiPriority = TryReadInt(root, "hiHiPriority"); model.LoLoDeadband = TryReadDouble(root, "loLoDeadband"); model.LoDeadband = TryReadDouble(root, "loDeadband"); model.HiDeadband = TryReadDouble(root, "hiDeadband"); model.HiHiDeadband = TryReadDouble(root, "hiHiDeadband"); model.LoLoMessage = TryReadString(root, "loLoMessage"); model.LoMessage = TryReadString(root, "loMessage"); model.HiMessage = TryReadString(root, "hiMessage"); model.HiHiMessage = TryReadString(root, "hiHiMessage"); break; case AlarmTriggerType.Expression: model.Expression = TryReadString(root, "expression"); break; } } catch (JsonException) { // Malformed JSON — fall through with default model. } return model; } /// /// Serializes the model to the JSON shape AlarmActor.ParseEvalConfig /// expects. Writes attributeName (canonical key) for the /// attribute-bound trigger types and only the keys relevant to the /// current trigger type. Expression is not bound to a single /// attribute, so attributeName is omitted for it. /// /// The AlarmTriggerModel to serialize. /// The alarm trigger type determining which properties to serialize. /// The serialized JSON representation of the model. internal static string Serialize(AlarmTriggerModel model, AlarmTriggerType type) { using var stream = new MemoryStream(); using (var w = new Utf8JsonWriter(stream)) { w.WriteStartObject(); if (type != AlarmTriggerType.Expression) w.WriteString("attributeName", model.AttributeName ?? ""); switch (type) { case AlarmTriggerType.ValueMatch: var mv = model.MatchValue ?? ""; if (model.NotEquals) mv = "!=" + mv; w.WriteString("matchValue", mv); break; case AlarmTriggerType.RangeViolation: if (model.Min.HasValue) w.WriteNumber("min", model.Min.Value); if (model.Max.HasValue) w.WriteNumber("max", model.Max.Value); break; case AlarmTriggerType.RateOfChange: if (model.ThresholdPerSecond.HasValue) w.WriteNumber("thresholdPerSecond", model.ThresholdPerSecond.Value); if (model.WindowSeconds.HasValue) w.WriteNumber("windowSeconds", model.WindowSeconds.Value); w.WriteString("direction", model.Direction); break; case AlarmTriggerType.HiLo: if (model.LoLo.HasValue) w.WriteNumber("loLo", model.LoLo.Value); if (model.Lo.HasValue) w.WriteNumber("lo", model.Lo.Value); if (model.Hi.HasValue) w.WriteNumber("hi", model.Hi.Value); if (model.HiHi.HasValue) w.WriteNumber("hiHi", model.HiHi.Value); if (model.LoLoPriority.HasValue) w.WriteNumber("loLoPriority", model.LoLoPriority.Value); if (model.LoPriority.HasValue) w.WriteNumber("loPriority", model.LoPriority.Value); if (model.HiPriority.HasValue) w.WriteNumber("hiPriority", model.HiPriority.Value); if (model.HiHiPriority.HasValue) w.WriteNumber("hiHiPriority", model.HiHiPriority.Value); if (model.LoLoDeadband.HasValue) w.WriteNumber("loLoDeadband", model.LoLoDeadband.Value); if (model.LoDeadband.HasValue) w.WriteNumber("loDeadband", model.LoDeadband.Value); if (model.HiDeadband.HasValue) w.WriteNumber("hiDeadband", model.HiDeadband.Value); if (model.HiHiDeadband.HasValue) w.WriteNumber("hiHiDeadband", model.HiHiDeadband.Value); if (!string.IsNullOrEmpty(model.LoLoMessage)) w.WriteString("loLoMessage", model.LoLoMessage); if (!string.IsNullOrEmpty(model.LoMessage)) w.WriteString("loMessage", model.LoMessage); if (!string.IsNullOrEmpty(model.HiMessage)) w.WriteString("hiMessage", model.HiMessage); if (!string.IsNullOrEmpty(model.HiHiMessage)) w.WriteString("hiHiMessage", model.HiHiMessage); break; case AlarmTriggerType.Expression: w.WriteString("expression", model.Expression ?? ""); break; } w.WriteEndObject(); } return Encoding.UTF8.GetString(stream.ToArray()); } /// /// Normalizes a direction string to one of: rising, falling, or either. /// /// The raw direction string to normalize. /// Normalized direction: "rising", "falling", or "either". internal static string NormalizeDirection(string? raw) => raw?.ToLowerInvariant() switch { "rising" or "up" or "positive" => "rising", "falling" or "down" or "negative" => "falling", _ => "either" }; private static double? TryReadDouble(JsonElement el, string name) { if (!el.TryGetProperty(name, out var p)) return null; return p.ValueKind switch { JsonValueKind.Number => p.GetDouble(), JsonValueKind.String when double.TryParse(p.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var v) => v, _ => null }; } private static int? TryReadInt(JsonElement el, string name) { if (!el.TryGetProperty(name, out var p)) return null; return p.ValueKind switch { JsonValueKind.Number when p.TryGetInt32(out var i) => i, JsonValueKind.Number => (int)p.GetDouble(), JsonValueKind.String when int.TryParse(p.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) => v, _ => null }; } private static string? TryReadString(JsonElement el, string name) { if (!el.TryGetProperty(name, out var p)) return null; return p.ValueKind == JsonValueKind.String ? p.GetString() : null; } } internal sealed class AlarmTriggerModel { /// /// The attribute name bound to this trigger. /// public string? AttributeName { get; set; } // ValueMatch /// /// The value to match against the attribute for ValueMatch triggers. /// public string? MatchValue { get; set; } /// /// Indicates whether the match should be inverted (not equal) for ValueMatch triggers. /// public bool NotEquals { get; set; } // RangeViolation /// /// The minimum threshold for RangeViolation triggers. /// public double? Min { get; set; } /// /// The maximum threshold for RangeViolation triggers. /// public double? Max { get; set; } // RateOfChange /// /// The threshold per second for RateOfChange triggers. /// public double? ThresholdPerSecond { get; set; } /// /// The time window in seconds for RateOfChange rate calculation. /// public double? WindowSeconds { get; set; } /// /// The direction of change: "rising", "falling", or "either" for RateOfChange triggers. /// public string Direction { get; set; } = "either"; // HiLo — any subset of setpoints may be set; per-setpoint priorities // override the alarm-level priority for that band. /// /// The low-low setpoint for HiLo triggers. /// public double? LoLo { get; set; } /// /// The low setpoint for HiLo triggers. /// public double? Lo { get; set; } /// /// The high setpoint for HiLo triggers. /// public double? Hi { get; set; } /// /// The high-high setpoint for HiLo triggers. /// public double? HiHi { get; set; } /// /// The priority for low-low alarm state. /// public int? LoLoPriority { get; set; } /// /// The priority for low alarm state. /// public int? LoPriority { get; set; } /// /// The priority for high alarm state. /// public int? HiPriority { get; set; } /// /// The priority for high-high alarm state. /// public int? HiHiPriority { get; set; } // Hysteresis: optional deactivation deadband per setpoint. Once at the // band, the setpoint threshold is relaxed by this amount before the alarm // de-escalates. Prevents flapping when the value hovers at the boundary. /// /// The deadband for low-low alarm de-escalation. /// public double? LoLoDeadband { get; set; } /// /// The deadband for low alarm de-escalation. /// public double? LoDeadband { get; set; } /// /// The deadband for high alarm de-escalation. /// public double? HiDeadband { get; set; } /// /// The deadband for high-high alarm de-escalation. /// public double? HiHiDeadband { get; set; } // Per-band operator message. Optional; surfaces on AlarmStateChanged.Message // and may be used by notification routing or operator displays. /// /// The operator message for low-low alarm state. /// public string? LoLoMessage { get; set; } /// /// The operator message for low alarm state. /// public string? LoMessage { get; set; } /// /// The operator message for high alarm state. /// public string? HiMessage { get; set; } /// /// The operator message for high-high alarm state. /// public string? HiHiMessage { get; set; } // Expression — boolean C# expression evaluated on attribute updates. /// /// The boolean C# expression to evaluate for Expression triggers. /// public string? Expression { get; set; } }