feat(alarms): HiLo trigger type with per-band level, hysteresis, messages, overrides
Adds a new HiLo alarm trigger type with four configurable setpoints
(LoLo / Lo / Hi / HiHi). Each setpoint carries an optional priority,
deadband (for hysteresis), and operator message. The site runtime emits
AlarmStateChanged with an AlarmLevel field so consumers can differentiate
warning vs critical bands.
Plumbing:
- new AlarmLevel enum + AlarmStateChanged.Level/Message init properties
- AlarmTriggerEditor (Blazor) gets a HiLo render with severity tinting
- AlarmTriggerConfigCodec extracted from the editor for testability
- sitestream.proto carries level + message over gRPC
- SemanticValidator enforces numeric attribute, setpoint ordering,
non-negative deadband
- on-trigger scripts get an Alarm global (Name/Level/Priority/Message)
so notification routing can branch by severity
- per-instance InstanceAlarmOverride entity + EF migration + flattening
step + CLI commands; HiLo overrides merge setpoint-by-setpoint, binary
types whole-replace
- DebugView shows a Level badge + per-band message tooltip
- App.razor auto-reloads on permanent Blazor circuit failure
- docker/regen-proto.sh automates the proto regen workflow (the linux/arm64
protoc segfault means generated files are checked in for now)
This commit is contained in:
@@ -191,6 +191,7 @@
|
||||
<tr>
|
||||
<th>Alarm</th>
|
||||
<th>State</th>
|
||||
<th>Level</th>
|
||||
<th>Priority</th>
|
||||
<th>Timestamp</th>
|
||||
</tr>
|
||||
@@ -198,12 +199,30 @@
|
||||
<tbody aria-live="polite" aria-atomic="false">
|
||||
@foreach (var alarm in FilteredAlarmStates)
|
||||
{
|
||||
<tr class="@GetAlarmRowClass(alarm.State)">
|
||||
<td class="small">@alarm.AlarmName</td>
|
||||
<tr class="@GetAlarmRowClass(alarm.State)"
|
||||
title="@(string.IsNullOrEmpty(alarm.Message) ? null : alarm.Message)">
|
||||
<td class="small">
|
||||
@alarm.AlarmName
|
||||
@if (!string.IsNullOrEmpty(alarm.Message))
|
||||
{
|
||||
<span class="ms-1 text-info" aria-label="Has operator message">💬</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge @GetAlarmStateBadge(alarm.State)"
|
||||
aria-label="@($"Alarm state: {alarm.State}")">@alarm.State</span>
|
||||
</td>
|
||||
<td>
|
||||
@if (alarm.Level != AlarmLevel.None)
|
||||
{
|
||||
<span class="badge @GetAlarmLevelBadge(alarm.Level)"
|
||||
aria-label="@($"Alarm level: {alarm.Level}")">@FormatLevel(alarm.Level)</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted small">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="small">@alarm.Priority</td>
|
||||
<td class="small text-muted"
|
||||
title="@alarm.Timestamp.LocalDateTime.ToString("HH:mm:ss.fff")">
|
||||
@@ -468,6 +487,26 @@
|
||||
_ => ""
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Severity-tinted badge class for HiLo alarm levels. The critical bands
|
||||
/// (HighHigh / LowLow) get the danger class; warning bands get amber.
|
||||
/// </summary>
|
||||
private static string GetAlarmLevelBadge(AlarmLevel level) => level switch
|
||||
{
|
||||
AlarmLevel.HighHigh or AlarmLevel.LowLow => "bg-danger",
|
||||
AlarmLevel.High or AlarmLevel.Low => "bg-warning text-dark",
|
||||
_ => "bg-secondary"
|
||||
};
|
||||
|
||||
private static string FormatLevel(AlarmLevel level) => level switch
|
||||
{
|
||||
AlarmLevel.HighHigh => "HiHi",
|
||||
AlarmLevel.High => "Hi",
|
||||
AlarmLevel.Low => "Lo",
|
||||
AlarmLevel.LowLow => "LoLo",
|
||||
_ => "—"
|
||||
};
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_session != null)
|
||||
|
||||
@@ -754,7 +754,8 @@
|
||||
<AlarmTriggerEditor TriggerType="@_alarmTriggerType"
|
||||
Value="@_alarmTriggerConfig"
|
||||
ValueChanged="@(v => _alarmTriggerConfig = v)"
|
||||
AvailableAttributes="@BuildAlarmAttributeChoices()" />
|
||||
AvailableAttributes="@BuildAlarmAttributeChoices()"
|
||||
FallbackPriority="@_alarmPriority" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check">
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
|
||||
namespace ScadaLink.CentralUI.Components.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Round-trip codec for the alarm trigger configuration JSON used by both
|
||||
/// <see cref="AlarmTriggerEditor"/> (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 }
|
||||
///
|
||||
/// 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
|
||||
/// (<c>attribute</c>, <c>value</c>, <c>low</c>, <c>high</c>) so older configs
|
||||
/// survive a round-trip through the editor.
|
||||
/// </summary>
|
||||
internal static class AlarmTriggerConfigCodec
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Malformed JSON — fall through with default model.
|
||||
}
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the model to the JSON shape AlarmActor.ParseEvalConfig
|
||||
/// expects. Always writes <c>attributeName</c> (canonical key) and only
|
||||
/// the keys relevant to the current trigger type.
|
||||
/// </summary>
|
||||
internal static string Serialize(AlarmTriggerModel model, AlarmTriggerType type)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
using (var w = new Utf8JsonWriter(stream))
|
||||
{
|
||||
w.WriteStartObject();
|
||||
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;
|
||||
}
|
||||
|
||||
w.WriteEndObject();
|
||||
}
|
||||
return Encoding.UTF8.GetString(stream.ToArray());
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
public string? AttributeName { get; set; }
|
||||
|
||||
// ValueMatch
|
||||
public string? MatchValue { get; set; }
|
||||
public bool NotEquals { get; set; }
|
||||
|
||||
// RangeViolation
|
||||
public double? Min { get; set; }
|
||||
public double? Max { get; set; }
|
||||
|
||||
// RateOfChange
|
||||
public double? ThresholdPerSecond { get; set; }
|
||||
public double? WindowSeconds { get; set; }
|
||||
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.
|
||||
public double? LoLo { get; set; }
|
||||
public double? Lo { get; set; }
|
||||
public double? Hi { get; set; }
|
||||
public double? HiHi { get; set; }
|
||||
public int? LoLoPriority { get; set; }
|
||||
public int? LoPriority { get; set; }
|
||||
public int? HiPriority { get; set; }
|
||||
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.
|
||||
public double? LoLoDeadband { get; set; }
|
||||
public double? LoDeadband { get; set; }
|
||||
public double? HiDeadband { get; set; }
|
||||
public double? HiHiDeadband { get; set; }
|
||||
|
||||
// Per-band operator message. Optional; surfaces on AlarmStateChanged.Message
|
||||
// and may be used by notification routing or operator displays.
|
||||
public string? LoLoMessage { get; set; }
|
||||
public string? LoMessage { get; set; }
|
||||
public string? HiMessage { get; set; }
|
||||
public string? HiHiMessage { get; set; }
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
@namespace ScadaLink.CentralUI.Components.Shared
|
||||
@using System.Globalization
|
||||
@using System.IO
|
||||
@using System.Text
|
||||
@using System.Text.Json
|
||||
@using ScadaLink.Commons.Types.Enums
|
||||
|
||||
@* Rich alarm trigger configuration editor. Replaces the raw JSON text field
|
||||
@@ -83,6 +80,9 @@
|
||||
case AlarmTriggerType.RateOfChange:
|
||||
@RenderRateOfChange();
|
||||
break;
|
||||
case AlarmTriggerType.HiLo:
|
||||
@RenderHiLo();
|
||||
break;
|
||||
}
|
||||
|
||||
@* ── Hint ──────────────────────────────────────────────────────────── *@
|
||||
@@ -108,7 +108,7 @@
|
||||
|
||||
// ── Internal state ─────────────────────────────────────────────────────
|
||||
|
||||
private TriggerModel _model = new();
|
||||
private AlarmTriggerModel _model = new AlarmTriggerModel();
|
||||
private AlarmTriggerType _lastSeenType;
|
||||
private string? _lastSeenJson;
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
// the context of the new type. Missing/unparseable keys fall back to
|
||||
// empty defaults.
|
||||
var preservedAttr = _model.AttributeName;
|
||||
_model = Parse(Value, TriggerType);
|
||||
_model = AlarmTriggerConfigCodec.Parse(Value, TriggerType);
|
||||
if (jsonChanged == false && typeChanged && !string.IsNullOrEmpty(preservedAttr))
|
||||
_model.AttributeName = preservedAttr;
|
||||
|
||||
@@ -149,7 +149,7 @@
|
||||
|
||||
private async Task Emit()
|
||||
{
|
||||
var json = Serialize(_model, TriggerType);
|
||||
var json = AlarmTriggerConfigCodec.Serialize(_model, TriggerType);
|
||||
_lastSeenJson = json;
|
||||
await ValueChanged.InvokeAsync(json);
|
||||
}
|
||||
@@ -358,6 +358,150 @@
|
||||
|
||||
private string _directionText = "either";
|
||||
|
||||
// ── HiLo ───────────────────────────────────────────────────────────────
|
||||
|
||||
private RenderFragment RenderHiLo() => __builder =>
|
||||
{
|
||||
<div class="small text-muted mb-2 fst-italic">
|
||||
Set any subset of the four setpoints. The most-severe matching band
|
||||
wins (LoLo/HiHi outrank Lo/Hi). Per-setpoint priority overrides the
|
||||
alarm-level priority for that band. Deadband (optional) relaxes the
|
||||
deactivation threshold by the configured amount to prevent flapping.
|
||||
</div>
|
||||
|
||||
@HiLoSetpointRow("HIGH-HIGH (critical)",
|
||||
_hiHiText, v => _hiHiText = v, OnHiHiChanged,
|
||||
_hiHiDeadbandText, v => _hiHiDeadbandText = v, OnHiHiDeadbandChanged,
|
||||
_hiHiPriorityText, v => _hiHiPriorityText = v, OnHiHiPriorityChanged,
|
||||
_hiHiMessageText, v => _hiHiMessageText = v, OnHiHiMessageChanged,
|
||||
"text-danger")
|
||||
|
||||
@HiLoSetpointRow("HIGH (warning)",
|
||||
_hiText, v => _hiText = v, OnHiChanged,
|
||||
_hiDeadbandText, v => _hiDeadbandText = v, OnHiDeadbandChanged,
|
||||
_hiPriorityText, v => _hiPriorityText = v, OnHiPriorityChanged,
|
||||
_hiMessageText, v => _hiMessageText = v, OnHiMessageChanged,
|
||||
"text-warning-emphasis")
|
||||
|
||||
@HiLoSetpointRow("LOW (warning)",
|
||||
_loText, v => _loText = v, OnLoChanged,
|
||||
_loDeadbandText, v => _loDeadbandText = v, OnLoDeadbandChanged,
|
||||
_loPriorityText, v => _loPriorityText = v, OnLoPriorityChanged,
|
||||
_loMessageText, v => _loMessageText = v, OnLoMessageChanged,
|
||||
"text-warning-emphasis")
|
||||
|
||||
@HiLoSetpointRow("LOW-LOW (critical)",
|
||||
_loLoText, v => _loLoText = v, OnLoLoChanged,
|
||||
_loLoDeadbandText, v => _loLoDeadbandText = v, OnLoLoDeadbandChanged,
|
||||
_loLoPriorityText, v => _loLoPriorityText = v, OnLoLoPriorityChanged,
|
||||
_loLoMessageText, v => _loLoMessageText = v, OnLoLoMessageChanged,
|
||||
"text-danger")
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Renders one setpoint row: value (number) + priority (int). Both are
|
||||
/// optional — leaving a value blank disables that band. The
|
||||
/// <paramref name="severityClass"/> tints the label to convey relative
|
||||
/// severity at a glance.
|
||||
/// </summary>
|
||||
private RenderFragment HiLoSetpointRow(
|
||||
string label,
|
||||
string? value, Action<string?> valueSetter, Func<Task> onValueChanged,
|
||||
string? deadband, Action<string?> deadbandSetter, Func<Task> onDeadbandChanged,
|
||||
string? priority, Action<string?> prioritySetter, Func<Task> onPriorityChanged,
|
||||
string? message, Action<string?> messageSetter, Func<Task> onMessageChanged,
|
||||
string severityClass) => __builder =>
|
||||
{
|
||||
<div class="row g-2 align-items-end mb-1">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small text-uppercase fw-semibold mb-1 @severityClass">
|
||||
@label
|
||||
</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text">setpoint</span>
|
||||
<input type="number" step="any" class="form-control"
|
||||
placeholder="—"
|
||||
value="@value"
|
||||
@oninput="@(e => { valueSetter(e.Value?.ToString()); _ = onValueChanged(); })" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
|
||||
Deadband
|
||||
</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text">±</span>
|
||||
<input type="number" step="any" min="0" class="form-control"
|
||||
placeholder="0"
|
||||
value="@deadband"
|
||||
@oninput="@(e => { deadbandSetter(e.Value?.ToString()); _ = onDeadbandChanged(); })" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
|
||||
Priority
|
||||
</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="number" min="0" max="1000" class="form-control"
|
||||
placeholder="@_priority"
|
||||
value="@priority"
|
||||
@oninput="@(e => { prioritySetter(e.Value?.ToString()); _ = onPriorityChanged(); })" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-12">
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
placeholder="Optional operator message for this band…"
|
||||
value="@message"
|
||||
@oninput="@(e => { messageSetter(e.Value?.ToString()); _ = onMessageChanged(); })" />
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
|
||||
// Setpoint text mirrors — separate from the model so blank fields stay
|
||||
// blank (rather than appearing as 0) and we can detect "unset" cleanly.
|
||||
private string? _loLoText;
|
||||
private string? _loText;
|
||||
private string? _hiText;
|
||||
private string? _hiHiText;
|
||||
private string? _loLoPriorityText;
|
||||
private string? _loPriorityText;
|
||||
private string? _hiPriorityText;
|
||||
private string? _hiHiPriorityText;
|
||||
private string? _loLoDeadbandText;
|
||||
private string? _loDeadbandText;
|
||||
private string? _hiDeadbandText;
|
||||
private string? _hiHiDeadbandText;
|
||||
private string? _loLoMessageText;
|
||||
private string? _loMessageText;
|
||||
private string? _hiMessageText;
|
||||
private string? _hiHiMessageText;
|
||||
|
||||
// Mirrored on the parent so the placeholder shows the right fallback.
|
||||
[Parameter] public int FallbackPriority { get; set; } = 500;
|
||||
private int _priority => FallbackPriority;
|
||||
|
||||
private async Task OnLoLoChanged() { _model.LoLo = ParseDouble(_loLoText); await Emit(); }
|
||||
private async Task OnLoChanged() { _model.Lo = ParseDouble(_loText); await Emit(); }
|
||||
private async Task OnHiChanged() { _model.Hi = ParseDouble(_hiText); await Emit(); }
|
||||
private async Task OnHiHiChanged() { _model.HiHi = ParseDouble(_hiHiText); await Emit(); }
|
||||
private async Task OnLoLoPriorityChanged() { _model.LoLoPriority = ParseInt(_loLoPriorityText); await Emit(); }
|
||||
private async Task OnLoPriorityChanged() { _model.LoPriority = ParseInt(_loPriorityText); await Emit(); }
|
||||
private async Task OnHiPriorityChanged() { _model.HiPriority = ParseInt(_hiPriorityText); await Emit(); }
|
||||
private async Task OnHiHiPriorityChanged() { _model.HiHiPriority = ParseInt(_hiHiPriorityText); await Emit(); }
|
||||
private async Task OnLoLoDeadbandChanged() { _model.LoLoDeadband = ParseDouble(_loLoDeadbandText); await Emit(); }
|
||||
private async Task OnLoDeadbandChanged() { _model.LoDeadband = ParseDouble(_loDeadbandText); await Emit(); }
|
||||
private async Task OnHiDeadbandChanged() { _model.HiDeadband = ParseDouble(_hiDeadbandText); await Emit(); }
|
||||
private async Task OnHiHiDeadbandChanged() { _model.HiHiDeadband = ParseDouble(_hiHiDeadbandText); await Emit(); }
|
||||
private async Task OnLoLoMessageChanged() { _model.LoLoMessage = _loLoMessageText; await Emit(); }
|
||||
private async Task OnLoMessageChanged() { _model.LoMessage = _loMessageText; await Emit(); }
|
||||
private async Task OnHiMessageChanged() { _model.HiMessage = _hiMessageText; await Emit(); }
|
||||
private async Task OnHiHiMessageChanged() { _model.HiHiMessage = _hiHiMessageText; await Emit(); }
|
||||
|
||||
private static int? ParseInt(string? s) =>
|
||||
int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? v : null;
|
||||
|
||||
// ── Text mirrors for typed inputs ──────────────────────────────────────
|
||||
// @bind requires a settable backing field that round-trips text. We keep
|
||||
// these in sync with the model and re-parse on @bind:after.
|
||||
@@ -382,6 +526,22 @@
|
||||
_thresholdText = FormatNullable(_model.ThresholdPerSecond);
|
||||
_windowText = FormatNullable(_model.WindowSeconds);
|
||||
_directionText = _model.Direction;
|
||||
_loLoText = FormatNullable(_model.LoLo);
|
||||
_loText = FormatNullable(_model.Lo);
|
||||
_hiText = FormatNullable(_model.Hi);
|
||||
_hiHiText = FormatNullable(_model.HiHi);
|
||||
_loLoPriorityText = _model.LoLoPriority?.ToString(CultureInfo.InvariantCulture);
|
||||
_loPriorityText = _model.LoPriority?.ToString(CultureInfo.InvariantCulture);
|
||||
_hiPriorityText = _model.HiPriority?.ToString(CultureInfo.InvariantCulture);
|
||||
_hiHiPriorityText = _model.HiHiPriority?.ToString(CultureInfo.InvariantCulture);
|
||||
_loLoDeadbandText = FormatNullable(_model.LoLoDeadband);
|
||||
_loDeadbandText = FormatNullable(_model.LoDeadband);
|
||||
_hiDeadbandText = FormatNullable(_model.HiDeadband);
|
||||
_hiHiDeadbandText = FormatNullable(_model.HiHiDeadband);
|
||||
_loLoMessageText = _model.LoLoMessage ?? string.Empty;
|
||||
_loMessageText = _model.LoMessage ?? string.Empty;
|
||||
_hiMessageText = _model.HiMessage ?? string.Empty;
|
||||
_hiHiMessageText = _model.HiHiMessage ?? string.Empty;
|
||||
}
|
||||
|
||||
private string _operatorText = "eq";
|
||||
@@ -420,10 +580,25 @@
|
||||
AlarmTriggerType.RateOfChange =>
|
||||
$"Triggers when {attr} changes faster than {Fmt(_model.ThresholdPerSecond) ?? "?"} units/sec ({_model.Direction}) over a {Fmt(_model.WindowSeconds) ?? "?"} sec window.",
|
||||
|
||||
AlarmTriggerType.HiLo => BuildHiLoHint(attr),
|
||||
|
||||
_ => string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private string BuildHiLoHint(string attr)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (_model.LoLo.HasValue) parts.Add($"LoLo at {Fmt(_model.LoLo)}");
|
||||
if (_model.Lo.HasValue) parts.Add($"Lo at {Fmt(_model.Lo)}");
|
||||
if (_model.Hi.HasValue) parts.Add($"Hi at {Fmt(_model.Hi)}");
|
||||
if (_model.HiHi.HasValue) parts.Add($"HiHi at {Fmt(_model.HiHi)}");
|
||||
|
||||
if (parts.Count == 0)
|
||||
return $"Triggers when {attr} crosses any configured setpoint (none set yet).";
|
||||
return $"Triggers on {attr}: {string.Join(", ", parts)}.";
|
||||
}
|
||||
|
||||
private static string Fmt(double? v) =>
|
||||
v.HasValue ? v.Value.ToString("0.###", CultureInfo.InvariantCulture) : "";
|
||||
|
||||
@@ -433,140 +608,4 @@
|
||||
private static double? ParseDouble(string? s) =>
|
||||
double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : null;
|
||||
|
||||
// ── Model + parse/serialize ────────────────────────────────────────────
|
||||
|
||||
private sealed class TriggerModel
|
||||
{
|
||||
public string? AttributeName { get; set; }
|
||||
|
||||
// ValueMatch
|
||||
public string? MatchValue { get; set; }
|
||||
public bool NotEquals { get; set; }
|
||||
|
||||
// RangeViolation
|
||||
public double? Min { get; set; }
|
||||
public double? Max { get; set; }
|
||||
|
||||
// RateOfChange
|
||||
public double? ThresholdPerSecond { get; set; }
|
||||
public double? WindowSeconds { get; set; }
|
||||
public string Direction { get; set; } = "either";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses an existing trigger configuration JSON in the context of the
|
||||
/// given trigger type. Returns sensible defaults on parse failure or for
|
||||
/// missing keys.
|
||||
/// </summary>
|
||||
private static TriggerModel Parse(string? json, AlarmTriggerType type)
|
||||
{
|
||||
var model = new TriggerModel();
|
||||
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;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Malformed JSON — fall through with default model.
|
||||
}
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
private 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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the model to the JSON shape AlarmActor.ParseEvalConfig
|
||||
/// expects. Always writes <c>attributeName</c> (canonical key) and only
|
||||
/// the keys relevant to the current trigger type.
|
||||
/// </summary>
|
||||
private static string Serialize(TriggerModel model, AlarmTriggerType type)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
using (var w = new Utf8JsonWriter(stream))
|
||||
{
|
||||
w.WriteStartObject();
|
||||
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;
|
||||
}
|
||||
|
||||
w.WriteEndObject();
|
||||
}
|
||||
return System.Text.Encoding.UTF8.GetString(stream.ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user