From bbc3804d07be417d66006791ae2c5b38fb22156a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 7 Jun 2026 10:02:51 -0400 Subject: [PATCH] feat(cli): typed setpoint flags for template alarm add (serializes trigger-config JSON) --- .../AlarmTriggerConfigJson.cs | 80 +++++++++++++++++++ .../Commands/TemplateCommands.cs | 44 +++++++++- 2 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.CLI/AlarmTriggerConfigJson.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/AlarmTriggerConfigJson.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/AlarmTriggerConfigJson.cs new file mode 100644 index 00000000..f451a42f --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/AlarmTriggerConfigJson.cs @@ -0,0 +1,80 @@ +using System.Text; +using System.Text.Json; + +namespace ZB.MOM.WW.ScadaBridge.CLI; + +/// +/// Serializes typed alarm-setpoint CLI flags into the trigger-config JSON the +/// server expects. Key names MUST stay in lockstep with the canonical codec at +/// src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/AlarmTriggerConfigCodec.cs +/// (that codec is internal to CentralUI, so this is a deliberate CLI-side mirror; +/// the round-trip test verifies the JSON against the live server — the real contract). +/// +internal static class AlarmTriggerConfigJson +{ + /// + /// Builds the trigger-config JSON for from the typed + /// flags, or returns null when none are supplied (so the alarm is created without a + /// trigger config). Unknown/blank trigger types yield null. + /// + internal static string? Build( + string triggerType, string? attribute, + string? matchValue, bool notEquals, + double? min, double? max, + double? thresholdPerSecond, double? windowSeconds, string? direction, + double? loLo, double? lo, double? hi, double? hiHi, + string? expression) + { + var type = triggerType?.Trim(); + var anyTyped = attribute is not null || matchValue is not null || notEquals + || min.HasValue || max.HasValue || thresholdPerSecond.HasValue || windowSeconds.HasValue + || direction is not null || loLo.HasValue || lo.HasValue || hi.HasValue || hiHi.HasValue + || expression is not null; + if (!anyTyped) return null; + + using var stream = new MemoryStream(); + using (var w = new Utf8JsonWriter(stream)) + { + w.WriteStartObject(); + if (!string.Equals(type, "Expression", StringComparison.OrdinalIgnoreCase)) + w.WriteString("attributeName", attribute ?? ""); + + switch (type?.ToLowerInvariant()) + { + case "valuematch": + var mv = matchValue ?? ""; + if (notEquals) mv = "!=" + mv; + w.WriteString("matchValue", mv); + break; + case "rangeviolation": + if (min.HasValue) w.WriteNumber("min", min.Value); + if (max.HasValue) w.WriteNumber("max", max.Value); + break; + case "rateofchange": + if (thresholdPerSecond.HasValue) w.WriteNumber("thresholdPerSecond", thresholdPerSecond.Value); + if (windowSeconds.HasValue) w.WriteNumber("windowSeconds", windowSeconds.Value); + w.WriteString("direction", NormalizeDirection(direction)); + break; + case "hilo": + if (loLo.HasValue) w.WriteNumber("loLo", loLo.Value); + if (lo.HasValue) w.WriteNumber("lo", lo.Value); + if (hi.HasValue) w.WriteNumber("hi", hi.Value); + if (hiHi.HasValue) w.WriteNumber("hiHi", hiHi.Value); + break; + case "expression": + w.WriteString("expression", expression ?? ""); + break; + } + w.WriteEndObject(); + } + return Encoding.UTF8.GetString(stream.ToArray()); + } + + // Mirrors AlarmTriggerConfigCodec.NormalizeDirection. + private static string NormalizeDirection(string? raw) => raw?.ToLowerInvariant() switch + { + "rising" or "up" or "positive" => "rising", + "falling" or "down" or "negative" => "falling", + _ => "either", + }; +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs index 9d88a4fa..5201e225 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs @@ -226,6 +226,22 @@ public static class TemplateCommands var lockedOption = new Option("--locked") { Description = "Lock status" }; lockedOption.DefaultValueFactory = _ => false; + // Typed setpoint flags (alternative to raw --trigger-config; raw wins when both supplied). + var attributeOption = new Option("--attribute") { Description = "Attribute name the trigger watches (all trigger types except Expression)" }; + var matchValueOption = new Option("--match-value") { Description = "ValueMatch: value to compare against" }; + var notEqualsOption = new Option("--not-equals") { Description = "ValueMatch: match when the value is NOT equal (emits !=)" }; + notEqualsOption.DefaultValueFactory = _ => false; + var minOption = new Option("--min") { Description = "RangeViolation: minimum allowed value" }; + var maxOption = new Option("--max") { Description = "RangeViolation: maximum allowed value" }; + var thresholdOption = new Option("--threshold-per-second") { Description = "RateOfChange: rate threshold per second" }; + var windowOption = new Option("--window-seconds") { Description = "RateOfChange: sliding window in seconds" }; + var directionOption = new Option("--direction") { Description = "RateOfChange: direction (rising|falling|either)" }; + var loLoOption = new Option("--lolo") { Description = "HiLo: low-low setpoint" }; + var loOption = new Option("--lo") { Description = "HiLo: low setpoint" }; + var hiOption = new Option("--hi") { Description = "HiLo: high setpoint" }; + var hiHiOption = new Option("--hihi") { Description = "HiLo: high-high setpoint" }; + var expressionOption = new Option("--expression") { Description = "Expression: boolean trigger expression" }; + var addCmd = new Command("add") { Description = "Add an alarm to a template" }; addCmd.Add(templateIdOption); addCmd.Add(nameOption); @@ -234,17 +250,41 @@ public static class TemplateCommands addCmd.Add(descOption); addCmd.Add(triggerConfigOption); addCmd.Add(lockedOption); + addCmd.Add(attributeOption); + addCmd.Add(matchValueOption); + addCmd.Add(notEqualsOption); + addCmd.Add(minOption); + addCmd.Add(maxOption); + addCmd.Add(thresholdOption); + addCmd.Add(windowOption); + addCmd.Add(directionOption); + addCmd.Add(loLoOption); + addCmd.Add(loOption); + addCmd.Add(hiOption); + addCmd.Add(hiHiOption); + addCmd.Add(expressionOption); addCmd.SetAction(async (ParseResult result) => { + var triggerType = result.GetValue(triggerTypeOption)!; + var rawConfig = result.GetValue(triggerConfigOption); + var triggerConfig = rawConfig ?? AlarmTriggerConfigJson.Build( + triggerType, + result.GetValue(attributeOption), + result.GetValue(matchValueOption), result.GetValue(notEqualsOption), + result.GetValue(minOption), result.GetValue(maxOption), + result.GetValue(thresholdOption), result.GetValue(windowOption), result.GetValue(directionOption), + result.GetValue(loLoOption), result.GetValue(loOption), result.GetValue(hiOption), result.GetValue(hiHiOption), + result.GetValue(expressionOption)); + return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, new AddTemplateAlarmCommand( result.GetValue(templateIdOption), result.GetValue(nameOption)!, - result.GetValue(triggerTypeOption)!, + triggerType, result.GetValue(priorityOption)!, result.GetValue(descOption), - result.GetValue(triggerConfigOption), + triggerConfig, result.GetValue(lockedOption))); }); group.Add(addCmd);