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:
92
docker/regen-proto.sh
Executable file
92
docker/regen-proto.sh
Executable file
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Regenerates the gRPC C# files from sitestream.proto.
|
||||
#
|
||||
# Background: protoc (linux/arm64) segfaults inside our Docker build container
|
||||
# (Grpc.Tools 2.71.0). As a workaround the generated Sitestream.cs +
|
||||
# SitestreamGrpc.cs are checked into src/ScadaLink.Communication/SiteStreamGrpc/
|
||||
# and the Protobuf ItemGroup in the .csproj is commented out — Docker just
|
||||
# compiles the checked-in C# files.
|
||||
#
|
||||
# Run this script ON YOUR DEV MACHINE whenever Protos/sitestream.proto changes:
|
||||
#
|
||||
# 1. Temporarily uncomments the Protobuf ItemGroup so Grpc.Tools runs.
|
||||
# 2. dotnet build (regen writes fresh files to obj/).
|
||||
# 3. Copies the regenerated files back into SiteStreamGrpc/.
|
||||
# 4. Re-comments the Protobuf ItemGroup so Docker builds stay safe.
|
||||
#
|
||||
# Once we move to a Dockerfile base image that ships a working linux/arm64
|
||||
# protoc, this script can be retired and Docker can regen the proto on every
|
||||
# build like every other normal .NET project.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
COMM_DIR="$REPO_ROOT/src/ScadaLink.Communication"
|
||||
CSPROJ="$COMM_DIR/ScadaLink.Communication.csproj"
|
||||
GEN_DIR="$COMM_DIR/SiteStreamGrpc"
|
||||
|
||||
echo "=== Regenerating gRPC files from sitestream.proto ==="
|
||||
|
||||
if [[ ! -f "$CSPROJ" ]]; then
|
||||
echo "ERROR: csproj not found at $CSPROJ" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Backup so we can always restore the comment state on failure.
|
||||
BACKUP="$(mktemp)"
|
||||
cp "$CSPROJ" "$BACKUP"
|
||||
trap 'cp "$BACKUP" "$CSPROJ"; rm -f "$BACKUP"; echo "Restored csproj from backup."' ERR
|
||||
|
||||
# 1. Uncomment the Protobuf ItemGroup (strip the surrounding <!-- ... --> wrapper).
|
||||
python3 - <<PY
|
||||
import re, pathlib
|
||||
p = pathlib.Path("$CSPROJ")
|
||||
src = p.read_text()
|
||||
# Find the commented Protobuf block and unwrap it.
|
||||
new = re.sub(
|
||||
r"<!--\s*\n(\s*<ItemGroup>\s*\n\s*<Protobuf [^>]*/>\s*\n\s*</ItemGroup>)\s*\n\s*-->",
|
||||
r"\1",
|
||||
src,
|
||||
count=1,
|
||||
)
|
||||
if new == src:
|
||||
raise SystemExit("Couldn't find commented Protobuf ItemGroup to enable.")
|
||||
p.write_text(new)
|
||||
PY
|
||||
|
||||
# 2. Delete the stale files so any failure to regen is obvious.
|
||||
rm -f "$GEN_DIR/Sitestream.cs" "$GEN_DIR/SitestreamGrpc.cs"
|
||||
|
||||
# 3. Regenerate by building.
|
||||
echo "Building Communication project (regen)..."
|
||||
dotnet build "$CSPROJ" --nologo -v minimal | tail -5
|
||||
|
||||
# 4. Copy generated files back into the source tree.
|
||||
mkdir -p "$GEN_DIR"
|
||||
cp "$COMM_DIR/obj/Debug/net10.0/Protos/Sitestream.cs" "$GEN_DIR/Sitestream.cs"
|
||||
cp "$COMM_DIR/obj/Debug/net10.0/Protos/SitestreamGrpc.cs" "$GEN_DIR/SitestreamGrpc.cs"
|
||||
echo "Copied regenerated files to $GEN_DIR/"
|
||||
|
||||
# 5. Re-comment the Protobuf ItemGroup so Docker builds keep working.
|
||||
python3 - <<PY
|
||||
import re, pathlib
|
||||
p = pathlib.Path("$CSPROJ")
|
||||
src = p.read_text()
|
||||
new = re.sub(
|
||||
r"(\s*<ItemGroup>\s*\n\s*<Protobuf [^>]*/>\s*\n\s*</ItemGroup>)",
|
||||
r"\n <!--\1\n -->",
|
||||
src,
|
||||
count=1,
|
||||
)
|
||||
p.write_text(new)
|
||||
PY
|
||||
|
||||
rm -f "$BACKUP"
|
||||
trap - ERR
|
||||
|
||||
echo ""
|
||||
echo "Done. Review and commit:"
|
||||
echo " git diff src/ScadaLink.Communication/Protos/sitestream.proto"
|
||||
echo " git diff src/ScadaLink.Communication/SiteStreamGrpc/"
|
||||
@@ -32,7 +32,8 @@ Commons must define shared primitive and utility types used across multiple comp
|
||||
- **`InstanceState` enum**: Enabled, Disabled.
|
||||
- **`DeploymentStatus` enum**: Pending, InProgress, Success, Failed.
|
||||
- **`AlarmState` enum**: Active, Normal.
|
||||
- **`AlarmTriggerType` enum**: ValueMatch, RangeViolation, RateOfChange.
|
||||
- **`AlarmLevel` enum**: None, Low, LowLow, High, HighHigh. Severity level for an active alarm; always `None` for binary trigger types, set by `HiLo` triggers.
|
||||
- **`AlarmTriggerType` enum**: ValueMatch, RangeViolation, RateOfChange, HiLo.
|
||||
- **`ConnectionHealth` enum**: Connected, Disconnected, Connecting, Error.
|
||||
|
||||
Types defined here must be immutable and thread-safe.
|
||||
|
||||
@@ -176,19 +176,20 @@ When the Instance Actor is stopped (due to disable, delete, or redeployment), Ak
|
||||
### Alarm Evaluation
|
||||
- Subscribes to attribute change notifications from its parent Instance Actor for the attribute(s) referenced by its trigger definition.
|
||||
- On each value update, evaluates the trigger condition:
|
||||
- **Value Match**: Incoming value equals the predefined target.
|
||||
- **Value Match**: Incoming value equals the predefined target. Supports `"!=X"` prefix for not-equals semantics.
|
||||
- **Range Violation**: Value is outside the allowed min/max range.
|
||||
- **Rate of Change**: Value change rate exceeds the defined threshold over time.
|
||||
- When the condition is met and the alarm is currently in **normal** state, the alarm transitions to **active**:
|
||||
- **Rate of Change**: Value change rate exceeds the defined threshold over a configurable time window. Direction filter (rising / falling / either) restricts which side of the rate triggers.
|
||||
- **HiLo**: Multi-setpoint level alarm with up to four configurable setpoints (LoLo, Lo, Hi, HiHi). Any subset may be configured. Each setpoint may carry its own priority that overrides the alarm-level priority for that band.
|
||||
- For binary trigger types (ValueMatch / RangeViolation / RateOfChange), when the condition is met and the alarm is currently in **normal** state, the alarm transitions to **active**:
|
||||
- Updates the alarm state on the parent Instance Actor (which publishes to the Akka stream).
|
||||
- If an on-trigger script is defined, spawns an Alarm Execution Actor to execute it.
|
||||
- When the condition clears and the alarm is in **active** state, the alarm transitions to **normal**:
|
||||
- Updates the alarm state on the parent Instance Actor.
|
||||
- No script execution on clear.
|
||||
- When the condition clears and the alarm is in **active** state, the alarm transitions to **normal**.
|
||||
- For HiLo triggers, the actor tracks the current `AlarmLevel` (None / Low / LowLow / High / HighHigh). Each level transition emits a fresh `AlarmStateChanged` with the new level and its priority; level escalations (e.g., High → HighHigh) and de-escalations (HighHigh → High) both produce events. The on-trigger script fires only on the Normal → non-None edge, not on escalations between alarm bands.
|
||||
- No script execution on clear in any trigger type.
|
||||
|
||||
### Alarm State
|
||||
- Held **in memory** only — not persisted to SQLite.
|
||||
- On restart (or failover), alarm states are re-evaluated from incoming values. All alarms start in normal state and transition to active when conditions are detected.
|
||||
- Held **in memory** only — not persisted to SQLite. State comprises `AlarmState` (Active / Normal) and `AlarmLevel` (None for binary triggers; the active band for HiLo).
|
||||
- On restart (or failover), alarm states are re-evaluated from incoming values. All alarms start in normal state with level None and transition based on incoming values.
|
||||
|
||||
### Alarm Execution Actor
|
||||
- **Short-lived** child actor created when an on-trigger script needs to execute.
|
||||
|
||||
@@ -106,16 +106,18 @@ Each alarm has:
|
||||
- **Priority Level**: Numeric value from 0–1000.
|
||||
- **Lock Flag**: Controls whether the alarm can be overridden downstream.
|
||||
- **Trigger Definition**: One of the following trigger types:
|
||||
- **Value Match**: Triggers when a monitored attribute equals a predefined value.
|
||||
- **Value Match**: Triggers when a monitored attribute equals a predefined value. Supports a `!=X` prefix on the match value for not-equals semantics.
|
||||
- **Range Violation**: Triggers when a monitored attribute value falls outside an allowed range.
|
||||
- **Rate of Change**: Triggers when a monitored attribute value changes faster than a defined threshold.
|
||||
- **Rate of Change**: Triggers when a monitored attribute value changes faster than a defined threshold over a configurable time window. A direction filter (rising / falling / either) restricts which side of the rate triggers.
|
||||
- **HiLo**: Multi-setpoint level alarm. Any subset of four setpoints (LoLo, Lo, Hi, HiHi) may be configured. The most severe matching band wins (LoLo/HiHi outrank Lo/Hi). Each setpoint may carry its own priority that overrides the alarm-level priority for that band.
|
||||
- **On-Trigger Script** *(optional)*: A script to execute when the alarm triggers. The alarm on-trigger script executes in the context of the instance and can call instance scripts, but instance scripts **cannot** call alarm on-trigger scripts. The call direction is one-way.
|
||||
|
||||
### 3.4.1 Alarm State
|
||||
- Alarm state (active/normal) is **managed at the site level** per instance, held **in memory** by the Alarm Actor.
|
||||
- Active alarms additionally carry an **alarm level**: `None` for binary trigger types (ValueMatch, RangeViolation, RateOfChange); one of `Low`, `LowLow`, `High`, `HighHigh` for HiLo triggers based on which setpoint the monitored attribute has crossed. Level transitions within an active HiLo alarm (e.g., High → HighHigh) emit fresh state-change events without re-running the on-trigger script — the script only fires on the Normal → non-None edge.
|
||||
- When the alarm condition clears, the alarm **automatically returns to normal state** — no acknowledgment workflow is required.
|
||||
- Alarm state is **not persisted** — on restart, alarm states are re-evaluated from incoming values.
|
||||
- Alarm state changes are published to the site-wide Akka stream as `[InstanceUniqueName].[AlarmName]`, alarm state (active/normal), priority, timestamp.
|
||||
- Alarm state changes are published to the site-wide Akka stream as `[InstanceUniqueName].[AlarmName]`, alarm state (active/normal), alarm level, priority, timestamp.
|
||||
|
||||
### 3.5 Template Relationships
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ public static class InstanceCommands
|
||||
command.Add(BuildCreate(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildSetBindings(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildSetOverrides(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildAlarmOverride(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildSetArea(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildDiff(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildDeploy(urlOption, formatOption, usernameOption, passwordOption));
|
||||
@@ -186,6 +187,59 @@ public static class InstanceCommands
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildAlarmOverride(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var group = new Command("alarm-override") { Description = "Manage per-instance alarm overrides" };
|
||||
|
||||
// set
|
||||
var setIdOption = new Option<int>("--instance-id") { Description = "Instance ID", Required = true };
|
||||
var setAlarmOption = new Option<string>("--alarm") { Description = "Alarm canonical name (e.g., 'TempLevels' or 'Pump.TempSensor.Heat')", Required = true };
|
||||
var setConfigOption = new Option<string?>("--trigger-config") { Description = "JSON override for TriggerConfiguration (HiLo: partial merge; others: whole-replace)" };
|
||||
var setPriorityOption = new Option<int?>("--priority") { Description = "Priority override (0-1000)" };
|
||||
var setCmd = new Command("set") { Description = "Set (upsert) an alarm override on an instance" };
|
||||
setCmd.Add(setIdOption); setCmd.Add(setAlarmOption); setCmd.Add(setConfigOption); setCmd.Add(setPriorityOption);
|
||||
setCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new SetInstanceAlarmOverrideCommand(
|
||||
result.GetValue(setIdOption),
|
||||
result.GetValue(setAlarmOption)!,
|
||||
result.GetValue(setConfigOption),
|
||||
result.GetValue(setPriorityOption)));
|
||||
});
|
||||
group.Add(setCmd);
|
||||
|
||||
// delete
|
||||
var delIdOption = new Option<int>("--instance-id") { Description = "Instance ID", Required = true };
|
||||
var delAlarmOption = new Option<string>("--alarm") { Description = "Alarm canonical name", Required = true };
|
||||
var delCmd = new Command("delete") { Description = "Remove an alarm override on an instance" };
|
||||
delCmd.Add(delIdOption); delCmd.Add(delAlarmOption);
|
||||
delCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new DeleteInstanceAlarmOverrideCommand(
|
||||
result.GetValue(delIdOption),
|
||||
result.GetValue(delAlarmOption)!));
|
||||
});
|
||||
group.Add(delCmd);
|
||||
|
||||
// list
|
||||
var listIdOption = new Option<int>("--instance-id") { Description = "Instance ID", Required = true };
|
||||
var listCmd = new Command("list") { Description = "List all alarm overrides for an instance" };
|
||||
listCmd.Add(listIdOption);
|
||||
listCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new ListInstanceAlarmOverridesCommand(result.GetValue(listIdOption)));
|
||||
});
|
||||
group.Add(listCmd);
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static Command BuildSetArea(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ public class Instance
|
||||
public string UniqueName { get; set; }
|
||||
public InstanceState State { get; set; }
|
||||
public ICollection<InstanceAttributeOverride> AttributeOverrides { get; set; } = new List<InstanceAttributeOverride>();
|
||||
public ICollection<InstanceAlarmOverride> AlarmOverrides { get; set; } = new List<InstanceAlarmOverride>();
|
||||
public ICollection<InstanceConnectionBinding> ConnectionBindings { get; set; } = new List<InstanceConnectionBinding>();
|
||||
|
||||
public Instance(string uniqueName)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace ScadaLink.Commons.Entities.Instances;
|
||||
|
||||
/// <summary>
|
||||
/// Per-instance override for a template-defined alarm. Lets a deployed
|
||||
/// instance tweak setpoints, priority, or per-band messages without forking
|
||||
/// the template. Locked alarms (TemplateAlarm.IsLocked) cannot be overridden
|
||||
/// — LockEnforcer rejects the change at write time.
|
||||
///
|
||||
/// Merge semantics (applied during flattening, after template inheritance and
|
||||
/// composition):
|
||||
/// • <see cref="TriggerConfigurationOverride"/> with a HiLo trigger merges
|
||||
/// into the inherited JSON setpoint-by-setpoint (derived keys win,
|
||||
/// inherited keys survive for unset derived keys). Same logic as
|
||||
/// template-to-template HiLo override, just one layer deeper.
|
||||
/// • For ValueMatch / RangeViolation / RateOfChange, the override replaces
|
||||
/// the whole TriggerConfiguration JSON (existing whole-replace semantics).
|
||||
/// • <see cref="PriorityLevelOverride"/> replaces the alarm's PriorityLevel
|
||||
/// when set.
|
||||
/// </summary>
|
||||
public class InstanceAlarmOverride
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int InstanceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Canonical name of the alarm being overridden — matches
|
||||
/// <c>ResolvedAlarm.CanonicalName</c> after flattening, so composed-member
|
||||
/// alarms are referenced as <c>[CompositionInstance].[AlarmName]</c>.
|
||||
/// </summary>
|
||||
public string AlarmCanonicalName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Partial JSON (for HiLo) or full JSON (for binary trigger types) to
|
||||
/// override the inherited TriggerConfiguration. <c>null</c> means
|
||||
/// "leave inherited as-is".
|
||||
/// </summary>
|
||||
public string? TriggerConfigurationOverride { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the alarm's PriorityLevel when set. <c>null</c> = keep inherited.
|
||||
/// </summary>
|
||||
public int? PriorityLevelOverride { get; set; }
|
||||
|
||||
public InstanceAlarmOverride(string alarmCanonicalName)
|
||||
{
|
||||
AlarmCanonicalName = alarmCanonicalName ?? throw new ArgumentNullException(nameof(alarmCanonicalName));
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,13 @@ public interface ITemplateEngineRepository
|
||||
Task UpdateInstanceAttributeOverrideAsync(InstanceAttributeOverride attributeOverride, CancellationToken cancellationToken = default);
|
||||
Task DeleteInstanceAttributeOverrideAsync(int id, CancellationToken cancellationToken = default);
|
||||
|
||||
// InstanceAlarmOverride
|
||||
Task<IReadOnlyList<InstanceAlarmOverride>> GetAlarmOverridesByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default);
|
||||
Task<InstanceAlarmOverride?> GetAlarmOverrideAsync(int instanceId, string alarmCanonicalName, CancellationToken cancellationToken = default);
|
||||
Task AddInstanceAlarmOverrideAsync(InstanceAlarmOverride alarmOverride, CancellationToken cancellationToken = default);
|
||||
Task UpdateInstanceAlarmOverrideAsync(InstanceAlarmOverride alarmOverride, CancellationToken cancellationToken = default);
|
||||
Task DeleteInstanceAlarmOverrideAsync(int id, CancellationToken cancellationToken = default);
|
||||
|
||||
// InstanceConnectionBinding
|
||||
Task<IReadOnlyList<InstanceConnectionBinding>> GetBindingsByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default);
|
||||
Task AddInstanceConnectionBindingAsync(InstanceConnectionBinding binding, CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -10,3 +10,21 @@ public record MgmtDeleteInstanceCommand(int InstanceId);
|
||||
public record SetConnectionBindingsCommand(int InstanceId, IReadOnlyList<(string AttributeName, int DataConnectionId)> Bindings);
|
||||
public record SetInstanceOverridesCommand(int InstanceId, IReadOnlyDictionary<string, string?> Overrides);
|
||||
public record SetInstanceAreaCommand(int InstanceId, int? AreaId);
|
||||
|
||||
/// <summary>
|
||||
/// Sets (or upserts) a per-instance alarm override. For HiLo trigger types the
|
||||
/// <c>TriggerConfigurationOverride</c> JSON is merged setpoint-by-setpoint with
|
||||
/// the inherited config; for binary trigger types it replaces the whole config.
|
||||
/// Either field is optional — pass null to leave it unchanged.
|
||||
/// </summary>
|
||||
public record SetInstanceAlarmOverrideCommand(
|
||||
int InstanceId,
|
||||
string AlarmCanonicalName,
|
||||
string? TriggerConfigurationOverride,
|
||||
int? PriorityLevelOverride);
|
||||
|
||||
public record DeleteInstanceAlarmOverrideCommand(
|
||||
int InstanceId,
|
||||
string AlarmCanonicalName);
|
||||
|
||||
public record ListInstanceAlarmOverridesCommand(int InstanceId);
|
||||
|
||||
@@ -7,4 +7,23 @@ public record AlarmStateChanged(
|
||||
string AlarmName,
|
||||
AlarmState State,
|
||||
int Priority,
|
||||
DateTimeOffset Timestamp);
|
||||
DateTimeOffset Timestamp)
|
||||
{
|
||||
/// <summary>
|
||||
/// Severity level when <see cref="State"/> is <see cref="AlarmState.Active"/>.
|
||||
/// Always <see cref="AlarmLevel.None"/> for binary trigger types
|
||||
/// (ValueMatch, RangeViolation, RateOfChange); set by the HiLo trigger
|
||||
/// type to one of Low/LowLow/High/HighHigh based on the crossed setpoint.
|
||||
/// Added as an init-property so existing positional constructors still
|
||||
/// work — message contract evolves additively.
|
||||
/// </summary>
|
||||
public AlarmLevel Level { get; init; } = AlarmLevel.None;
|
||||
|
||||
/// <summary>
|
||||
/// Optional per-band operator message (e.g., "Coolant critically low —
|
||||
/// shut down"). Set by HiLo triggers when the per-setpoint message is
|
||||
/// configured; otherwise empty. Notification routing and UI tooltips may
|
||||
/// surface this to operators.
|
||||
/// </summary>
|
||||
public string Message { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
19
src/ScadaLink.Commons/Types/Enums/AlarmLevel.cs
Normal file
19
src/ScadaLink.Commons/Types/Enums/AlarmLevel.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace ScadaLink.Commons.Types.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Severity level for an active alarm. Binary alarm types (ValueMatch,
|
||||
/// RangeViolation, RateOfChange) always emit <see cref="None"/>. The HiLo
|
||||
/// trigger type emits one of the directional levels based on which setpoint
|
||||
/// the monitored attribute has crossed.
|
||||
///
|
||||
/// Conventional ordering (lowest setpoint to highest):
|
||||
/// LowLow < Low < normal-band < High < HighHigh
|
||||
/// </summary>
|
||||
public enum AlarmLevel
|
||||
{
|
||||
None,
|
||||
Low,
|
||||
LowLow,
|
||||
High,
|
||||
HighHigh
|
||||
}
|
||||
@@ -4,5 +4,12 @@ public enum AlarmTriggerType
|
||||
{
|
||||
ValueMatch,
|
||||
RangeViolation,
|
||||
RateOfChange
|
||||
RateOfChange,
|
||||
/// <summary>
|
||||
/// Multi-setpoint level alarm: monitors a single numeric attribute against
|
||||
/// up to four configurable setpoints (LoLo, Lo, Hi, HiHi). Each setpoint
|
||||
/// may carry its own priority; transitions between levels emit a fresh
|
||||
/// AlarmStateChanged with the corresponding <see cref="AlarmLevel"/>.
|
||||
/// </summary>
|
||||
HiLo
|
||||
}
|
||||
|
||||
23
src/ScadaLink.Commons/Types/Scripts/AlarmContext.cs
Normal file
23
src/ScadaLink.Commons/Types/Scripts/AlarmContext.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
|
||||
namespace ScadaLink.Commons.Types.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Alarm context exposed to on-trigger scripts via <c>Alarm</c>. Lets scripts
|
||||
/// branch on the firing severity — e.g., page on-call for HiHi/LoLo but only
|
||||
/// email the day shift for Hi/Lo. Always present when an on-trigger script
|
||||
/// runs; <see cref="Level"/> is <see cref="AlarmLevel.None"/> for binary
|
||||
/// trigger types.
|
||||
/// </summary>
|
||||
public sealed class AlarmContext
|
||||
{
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public AlarmLevel Level { get; init; } = AlarmLevel.None;
|
||||
public int Priority { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Per-band operator message configured on the HiLo alarm, or empty for
|
||||
/// binary trigger types and bands without a message.
|
||||
/// </summary>
|
||||
public string Message { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using ScadaLink.Commons.Messages.Streaming;
|
||||
using ScadaLink.Commons.Types;
|
||||
using ScadaLink.Communication.Grpc;
|
||||
using AlarmState = ScadaLink.Commons.Types.Enums.AlarmState;
|
||||
using AlarmLevel = ScadaLink.Commons.Types.Enums.AlarmLevel;
|
||||
|
||||
namespace ScadaLink.Communication.Actors;
|
||||
|
||||
@@ -59,7 +60,9 @@ public class StreamRelayActor : ReceiveActor
|
||||
AlarmName = msg.AlarmName,
|
||||
State = MapAlarmState(msg.State),
|
||||
Priority = msg.Priority,
|
||||
Timestamp = Timestamp.FromDateTimeOffset(msg.Timestamp)
|
||||
Timestamp = Timestamp.FromDateTimeOffset(msg.Timestamp),
|
||||
Level = MapAlarmLevel(msg.Level),
|
||||
Message = msg.Message ?? string.Empty
|
||||
}
|
||||
};
|
||||
|
||||
@@ -88,4 +91,13 @@ public class StreamRelayActor : ReceiveActor
|
||||
AlarmState.Active => AlarmStateEnum.AlarmStateActive,
|
||||
_ => AlarmStateEnum.AlarmStateUnspecified
|
||||
};
|
||||
|
||||
private static AlarmLevelEnum MapAlarmLevel(AlarmLevel level) => level switch
|
||||
{
|
||||
AlarmLevel.Low => AlarmLevelEnum.AlarmLevelLow,
|
||||
AlarmLevel.LowLow => AlarmLevelEnum.AlarmLevelLowLow,
|
||||
AlarmLevel.High => AlarmLevelEnum.AlarmLevelHigh,
|
||||
AlarmLevel.HighHigh => AlarmLevelEnum.AlarmLevelHighHigh,
|
||||
_ => AlarmLevelEnum.AlarmLevelNone
|
||||
};
|
||||
}
|
||||
|
||||
@@ -137,7 +137,11 @@ public class SiteStreamGrpcClient : IAsyncDisposable
|
||||
evt.AlarmChanged.AlarmName,
|
||||
MapAlarmState(evt.AlarmChanged.State),
|
||||
evt.AlarmChanged.Priority,
|
||||
evt.AlarmChanged.Timestamp.ToDateTimeOffset()),
|
||||
evt.AlarmChanged.Timestamp.ToDateTimeOffset())
|
||||
{
|
||||
Level = MapAlarmLevel(evt.AlarmChanged.Level),
|
||||
Message = evt.AlarmChanged.Message ?? string.Empty
|
||||
},
|
||||
_ => null
|
||||
};
|
||||
|
||||
@@ -162,6 +166,18 @@ public class SiteStreamGrpcClient : IAsyncDisposable
|
||||
_ => AlarmState.Normal
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Maps proto AlarmLevelEnum to domain AlarmLevel. Internal for testability.
|
||||
/// </summary>
|
||||
internal static AlarmLevel MapAlarmLevel(AlarmLevelEnum level) => level switch
|
||||
{
|
||||
AlarmLevelEnum.AlarmLevelLow => AlarmLevel.Low,
|
||||
AlarmLevelEnum.AlarmLevelLowLow => AlarmLevel.LowLow,
|
||||
AlarmLevelEnum.AlarmLevelHigh => AlarmLevel.High,
|
||||
AlarmLevelEnum.AlarmLevelHighHigh => AlarmLevel.HighHigh,
|
||||
_ => AlarmLevel.None
|
||||
};
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
foreach (var cts in _subscriptions.Values)
|
||||
|
||||
@@ -34,6 +34,17 @@ enum AlarmStateEnum {
|
||||
ALARM_STATE_ACTIVE = 2;
|
||||
}
|
||||
|
||||
// Severity level for an active alarm. Binary trigger types (ValueMatch,
|
||||
// RangeViolation, RateOfChange) always emit ALARM_LEVEL_NONE. The HiLo
|
||||
// trigger type emits one of the directional values.
|
||||
enum AlarmLevelEnum {
|
||||
ALARM_LEVEL_NONE = 0;
|
||||
ALARM_LEVEL_LOW = 1;
|
||||
ALARM_LEVEL_LOW_LOW = 2;
|
||||
ALARM_LEVEL_HIGH = 3;
|
||||
ALARM_LEVEL_HIGH_HIGH = 4;
|
||||
}
|
||||
|
||||
message AttributeValueUpdate {
|
||||
string instance_unique_name = 1;
|
||||
string attribute_path = 2;
|
||||
@@ -49,4 +60,6 @@ message AlarmStateUpdate {
|
||||
AlarmStateEnum state = 3;
|
||||
int32 priority = 4;
|
||||
google.protobuf.Timestamp timestamp = 5;
|
||||
AlarmLevelEnum level = 6; // ALARM_LEVEL_NONE for binary trigger types; set by HiLo.
|
||||
string message = 7; // Optional per-band operator message; empty when unset.
|
||||
}
|
||||
|
||||
@@ -30,4 +30,21 @@
|
||||
<ProjectReference Include="../ScadaLink.HealthMonitoring/ScadaLink.HealthMonitoring.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- gRPC proto generation. The compiled C# is checked in under
|
||||
SiteStreamGrpc/ (Sitestream.cs + SitestreamGrpc.cs) because protoc
|
||||
segfaults inside our linux_arm64 Docker build image. To regenerate
|
||||
after schema changes:
|
||||
1. Temporarily uncomment the Protobuf ItemGroup below.
|
||||
2. Delete SiteStreamGrpc/*.cs.
|
||||
3. `dotnet build` (on macOS) — Grpc.Tools writes fresh files to obj/.
|
||||
4. Copy obj/Debug/net10.0/Protos/*.cs into SiteStreamGrpc/.
|
||||
5. Re-comment the ItemGroup.
|
||||
Eventually we should switch the Docker build image to one with a
|
||||
working protoc on arm64. -->
|
||||
<!--
|
||||
<ItemGroup>
|
||||
<Protobuf Include="Protos\sitestream.proto" GrpcServices="Both" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// <auto-generated>
|
||||
// Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
// source: sitestream.proto
|
||||
// source: Protos/sitestream.proto
|
||||
// </auto-generated>
|
||||
#pragma warning disable 1591, 0612, 3021, 8981
|
||||
#region Designer generated code
|
||||
@@ -11,11 +11,11 @@ using pbr = global::Google.Protobuf.Reflection;
|
||||
using scg = global::System.Collections.Generic;
|
||||
namespace ScadaLink.Communication.Grpc {
|
||||
|
||||
/// <summary>Holder for reflection information generated from sitestream.proto</summary>
|
||||
/// <summary>Holder for reflection information generated from Protos/sitestream.proto</summary>
|
||||
public static partial class SitestreamReflection {
|
||||
|
||||
#region Descriptor
|
||||
/// <summary>File descriptor for sitestream.proto</summary>
|
||||
/// <summary>File descriptor for Protos/sitestream.proto</summary>
|
||||
public static pbr::FileDescriptor Descriptor {
|
||||
get { return descriptor; }
|
||||
}
|
||||
@@ -24,36 +24,41 @@ namespace ScadaLink.Communication.Grpc {
|
||||
static SitestreamReflection() {
|
||||
byte[] descriptorData = global::System.Convert.FromBase64String(
|
||||
string.Concat(
|
||||
"ChBzaXRlc3RyZWFtLnByb3RvEgpzaXRlc3RyZWFtGh9nb29nbGUvcHJvdG9i",
|
||||
"dWYvdGltZXN0YW1wLnByb3RvIk0KFUluc3RhbmNlU3RyZWFtUmVxdWVzdBIW",
|
||||
"Cg5jb3JyZWxhdGlvbl9pZBgBIAEoCRIcChRpbnN0YW5jZV91bmlxdWVfbmFt",
|
||||
"ZRgCIAEoCSKoAQoPU2l0ZVN0cmVhbUV2ZW50EhYKDmNvcnJlbGF0aW9uX2lk",
|
||||
"GAEgASgJEj0KEWF0dHJpYnV0ZV9jaGFuZ2VkGAIgASgLMiAuc2l0ZXN0cmVh",
|
||||
"bS5BdHRyaWJ1dGVWYWx1ZVVwZGF0ZUgAEjUKDWFsYXJtX2NoYW5nZWQYAyAB",
|
||||
"KAsyHC5zaXRlc3RyZWFtLkFsYXJtU3RhdGVVcGRhdGVIAEIHCgVldmVudCLI",
|
||||
"AQoUQXR0cmlidXRlVmFsdWVVcGRhdGUSHAoUaW5zdGFuY2VfdW5pcXVlX25h",
|
||||
"bWUYASABKAkSFgoOYXR0cmlidXRlX3BhdGgYAiABKAkSFgoOYXR0cmlidXRl",
|
||||
"X25hbWUYAyABKAkSDQoFdmFsdWUYBCABKAkSJAoHcXVhbGl0eRgFIAEoDjIT",
|
||||
"LnNpdGVzdHJlYW0uUXVhbGl0eRItCgl0aW1lc3RhbXAYBiABKAsyGi5nb29n",
|
||||
"bGUucHJvdG9idWYuVGltZXN0YW1wIrABChBBbGFybVN0YXRlVXBkYXRlEhwK",
|
||||
"FGluc3RhbmNlX3VuaXF1ZV9uYW1lGAEgASgJEhIKCmFsYXJtX25hbWUYAiAB",
|
||||
"KAkSKQoFc3RhdGUYAyABKA4yGi5zaXRlc3RyZWFtLkFsYXJtU3RhdGVFbnVt",
|
||||
"EhAKCHByaW9yaXR5GAQgASgFEi0KCXRpbWVzdGFtcBgFIAEoCzIaLmdvb2ds",
|
||||
"ZS5wcm90b2J1Zi5UaW1lc3RhbXAqXAoHUXVhbGl0eRIXChNRVUFMSVRZX1VO",
|
||||
"U1BFQ0lGSUVEEAASEAoMUVVBTElUWV9HT09EEAESFQoRUVVBTElUWV9VTkNF",
|
||||
"UlRBSU4QAhIPCgtRVUFMSVRZX0JBRBADKl0KDkFsYXJtU3RhdGVFbnVtEhsK",
|
||||
"F0FMQVJNX1NUQVRFX1VOU1BFQ0lGSUVEEAASFgoSQUxBUk1fU1RBVEVfTk9S",
|
||||
"TUFMEAESFgoSQUxBUk1fU1RBVEVfQUNUSVZFEAIyagoRU2l0ZVN0cmVhbVNl",
|
||||
"cnZpY2USVQoRU3Vic2NyaWJlSW5zdGFuY2USIS5zaXRlc3RyZWFtLkluc3Rh",
|
||||
"bmNlU3RyZWFtUmVxdWVzdBobLnNpdGVzdHJlYW0uU2l0ZVN0cmVhbUV2ZW50",
|
||||
"MAFCH6oCHFNjYWRhTGluay5Db21tdW5pY2F0aW9uLkdycGNiBnByb3RvMw=="));
|
||||
"ChdQcm90b3Mvc2l0ZXN0cmVhbS5wcm90bxIKc2l0ZXN0cmVhbRofZ29vZ2xl",
|
||||
"L3Byb3RvYnVmL3RpbWVzdGFtcC5wcm90byJNChVJbnN0YW5jZVN0cmVhbVJl",
|
||||
"cXVlc3QSFgoOY29ycmVsYXRpb25faWQYASABKAkSHAoUaW5zdGFuY2VfdW5p",
|
||||
"cXVlX25hbWUYAiABKAkiqAEKD1NpdGVTdHJlYW1FdmVudBIWCg5jb3JyZWxh",
|
||||
"dGlvbl9pZBgBIAEoCRI9ChFhdHRyaWJ1dGVfY2hhbmdlZBgCIAEoCzIgLnNp",
|
||||
"dGVzdHJlYW0uQXR0cmlidXRlVmFsdWVVcGRhdGVIABI1Cg1hbGFybV9jaGFu",
|
||||
"Z2VkGAMgASgLMhwuc2l0ZXN0cmVhbS5BbGFybVN0YXRlVXBkYXRlSABCBwoF",
|
||||
"ZXZlbnQiyAEKFEF0dHJpYnV0ZVZhbHVlVXBkYXRlEhwKFGluc3RhbmNlX3Vu",
|
||||
"aXF1ZV9uYW1lGAEgASgJEhYKDmF0dHJpYnV0ZV9wYXRoGAIgASgJEhYKDmF0",
|
||||
"dHJpYnV0ZV9uYW1lGAMgASgJEg0KBXZhbHVlGAQgASgJEiQKB3F1YWxpdHkY",
|
||||
"BSABKA4yEy5zaXRlc3RyZWFtLlF1YWxpdHkSLQoJdGltZXN0YW1wGAYgASgL",
|
||||
"MhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCLsAQoQQWxhcm1TdGF0ZVVw",
|
||||
"ZGF0ZRIcChRpbnN0YW5jZV91bmlxdWVfbmFtZRgBIAEoCRISCgphbGFybV9u",
|
||||
"YW1lGAIgASgJEikKBXN0YXRlGAMgASgOMhouc2l0ZXN0cmVhbS5BbGFybVN0",
|
||||
"YXRlRW51bRIQCghwcmlvcml0eRgEIAEoBRItCgl0aW1lc3RhbXAYBSABKAsy",
|
||||
"Gi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEikKBWxldmVsGAYgASgOMhou",
|
||||
"c2l0ZXN0cmVhbS5BbGFybUxldmVsRW51bRIPCgdtZXNzYWdlGAcgASgJKlwK",
|
||||
"B1F1YWxpdHkSFwoTUVVBTElUWV9VTlNQRUNJRklFRBAAEhAKDFFVQUxJVFlf",
|
||||
"R09PRBABEhUKEVFVQUxJVFlfVU5DRVJUQUlOEAISDwoLUVVBTElUWV9CQUQQ",
|
||||
"AypdCg5BbGFybVN0YXRlRW51bRIbChdBTEFSTV9TVEFURV9VTlNQRUNJRklF",
|
||||
"RBAAEhYKEkFMQVJNX1NUQVRFX05PUk1BTBABEhYKEkFMQVJNX1NUQVRFX0FD",
|
||||
"VElWRRACKoUBCg5BbGFybUxldmVsRW51bRIUChBBTEFSTV9MRVZFTF9OT05F",
|
||||
"EAASEwoPQUxBUk1fTEVWRUxfTE9XEAESFwoTQUxBUk1fTEVWRUxfTE9XX0xP",
|
||||
"VxACEhQKEEFMQVJNX0xFVkVMX0hJR0gQAxIZChVBTEFSTV9MRVZFTF9ISUdI",
|
||||
"X0hJR0gQBDJqChFTaXRlU3RyZWFtU2VydmljZRJVChFTdWJzY3JpYmVJbnN0",
|
||||
"YW5jZRIhLnNpdGVzdHJlYW0uSW5zdGFuY2VTdHJlYW1SZXF1ZXN0Ghsuc2l0",
|
||||
"ZXN0cmVhbS5TaXRlU3RyZWFtRXZlbnQwAUIfqgIcU2NhZGFMaW5rLkNvbW11",
|
||||
"bmljYXRpb24uR3JwY2IGcHJvdG8z"));
|
||||
descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData,
|
||||
new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, },
|
||||
new pbr::GeneratedClrTypeInfo(new[] {typeof(global::ScadaLink.Communication.Grpc.Quality), typeof(global::ScadaLink.Communication.Grpc.AlarmStateEnum), }, null, new pbr::GeneratedClrTypeInfo[] {
|
||||
new pbr::GeneratedClrTypeInfo(new[] {typeof(global::ScadaLink.Communication.Grpc.Quality), typeof(global::ScadaLink.Communication.Grpc.AlarmStateEnum), typeof(global::ScadaLink.Communication.Grpc.AlarmLevelEnum), }, null, new pbr::GeneratedClrTypeInfo[] {
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.InstanceStreamRequest), global::ScadaLink.Communication.Grpc.InstanceStreamRequest.Parser, new[]{ "CorrelationId", "InstanceUniqueName" }, null, null, null, null),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.SiteStreamEvent), global::ScadaLink.Communication.Grpc.SiteStreamEvent.Parser, new[]{ "CorrelationId", "AttributeChanged", "AlarmChanged" }, new[]{ "Event" }, null, null, null),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AttributeValueUpdate), global::ScadaLink.Communication.Grpc.AttributeValueUpdate.Parser, new[]{ "InstanceUniqueName", "AttributePath", "AttributeName", "Value", "Quality", "Timestamp" }, null, null, null, null),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AlarmStateUpdate), global::ScadaLink.Communication.Grpc.AlarmStateUpdate.Parser, new[]{ "InstanceUniqueName", "AlarmName", "State", "Priority", "Timestamp" }, null, null, null, null)
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AlarmStateUpdate), global::ScadaLink.Communication.Grpc.AlarmStateUpdate.Parser, new[]{ "InstanceUniqueName", "AlarmName", "State", "Priority", "Timestamp", "Level", "Message" }, null, null, null, null)
|
||||
}));
|
||||
}
|
||||
#endregion
|
||||
@@ -73,6 +78,19 @@ namespace ScadaLink.Communication.Grpc {
|
||||
[pbr::OriginalName("ALARM_STATE_ACTIVE")] AlarmStateActive = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Severity level for an active alarm. Binary trigger types (ValueMatch,
|
||||
/// RangeViolation, RateOfChange) always emit ALARM_LEVEL_NONE. The HiLo
|
||||
/// trigger type emits one of the directional values.
|
||||
/// </summary>
|
||||
public enum AlarmLevelEnum {
|
||||
[pbr::OriginalName("ALARM_LEVEL_NONE")] AlarmLevelNone = 0,
|
||||
[pbr::OriginalName("ALARM_LEVEL_LOW")] AlarmLevelLow = 1,
|
||||
[pbr::OriginalName("ALARM_LEVEL_LOW_LOW")] AlarmLevelLowLow = 2,
|
||||
[pbr::OriginalName("ALARM_LEVEL_HIGH")] AlarmLevelHigh = 3,
|
||||
[pbr::OriginalName("ALARM_LEVEL_HIGH_HIGH")] AlarmLevelHighHigh = 4,
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Messages
|
||||
@@ -1074,6 +1092,8 @@ namespace ScadaLink.Communication.Grpc {
|
||||
state_ = other.state_;
|
||||
priority_ = other.priority_;
|
||||
timestamp_ = other.timestamp_ != null ? other.timestamp_.Clone() : null;
|
||||
level_ = other.level_;
|
||||
message_ = other.message_;
|
||||
_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
|
||||
}
|
||||
|
||||
@@ -1143,6 +1163,36 @@ namespace ScadaLink.Communication.Grpc {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Field number for the "level" field.</summary>
|
||||
public const int LevelFieldNumber = 6;
|
||||
private global::ScadaLink.Communication.Grpc.AlarmLevelEnum level_ = global::ScadaLink.Communication.Grpc.AlarmLevelEnum.AlarmLevelNone;
|
||||
/// <summary>
|
||||
/// ALARM_LEVEL_NONE for binary trigger types; set by HiLo.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public global::ScadaLink.Communication.Grpc.AlarmLevelEnum Level {
|
||||
get { return level_; }
|
||||
set {
|
||||
level_ = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Field number for the "message" field.</summary>
|
||||
public const int MessageFieldNumber = 7;
|
||||
private string message_ = "";
|
||||
/// <summary>
|
||||
/// Optional per-band operator message; empty when unset.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public string Message {
|
||||
get { return message_; }
|
||||
set {
|
||||
message_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
|
||||
}
|
||||
}
|
||||
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public override bool Equals(object other) {
|
||||
@@ -1163,6 +1213,8 @@ namespace ScadaLink.Communication.Grpc {
|
||||
if (State != other.State) return false;
|
||||
if (Priority != other.Priority) return false;
|
||||
if (!object.Equals(Timestamp, other.Timestamp)) return false;
|
||||
if (Level != other.Level) return false;
|
||||
if (Message != other.Message) return false;
|
||||
return Equals(_unknownFields, other._unknownFields);
|
||||
}
|
||||
|
||||
@@ -1175,6 +1227,8 @@ namespace ScadaLink.Communication.Grpc {
|
||||
if (State != global::ScadaLink.Communication.Grpc.AlarmStateEnum.AlarmStateUnspecified) hash ^= State.GetHashCode();
|
||||
if (Priority != 0) hash ^= Priority.GetHashCode();
|
||||
if (timestamp_ != null) hash ^= Timestamp.GetHashCode();
|
||||
if (Level != global::ScadaLink.Communication.Grpc.AlarmLevelEnum.AlarmLevelNone) hash ^= Level.GetHashCode();
|
||||
if (Message.Length != 0) hash ^= Message.GetHashCode();
|
||||
if (_unknownFields != null) {
|
||||
hash ^= _unknownFields.GetHashCode();
|
||||
}
|
||||
@@ -1213,6 +1267,14 @@ namespace ScadaLink.Communication.Grpc {
|
||||
output.WriteRawTag(42);
|
||||
output.WriteMessage(Timestamp);
|
||||
}
|
||||
if (Level != global::ScadaLink.Communication.Grpc.AlarmLevelEnum.AlarmLevelNone) {
|
||||
output.WriteRawTag(48);
|
||||
output.WriteEnum((int) Level);
|
||||
}
|
||||
if (Message.Length != 0) {
|
||||
output.WriteRawTag(58);
|
||||
output.WriteString(Message);
|
||||
}
|
||||
if (_unknownFields != null) {
|
||||
_unknownFields.WriteTo(output);
|
||||
}
|
||||
@@ -1243,6 +1305,14 @@ namespace ScadaLink.Communication.Grpc {
|
||||
output.WriteRawTag(42);
|
||||
output.WriteMessage(Timestamp);
|
||||
}
|
||||
if (Level != global::ScadaLink.Communication.Grpc.AlarmLevelEnum.AlarmLevelNone) {
|
||||
output.WriteRawTag(48);
|
||||
output.WriteEnum((int) Level);
|
||||
}
|
||||
if (Message.Length != 0) {
|
||||
output.WriteRawTag(58);
|
||||
output.WriteString(Message);
|
||||
}
|
||||
if (_unknownFields != null) {
|
||||
_unknownFields.WriteTo(ref output);
|
||||
}
|
||||
@@ -1268,6 +1338,12 @@ namespace ScadaLink.Communication.Grpc {
|
||||
if (timestamp_ != null) {
|
||||
size += 1 + pb::CodedOutputStream.ComputeMessageSize(Timestamp);
|
||||
}
|
||||
if (Level != global::ScadaLink.Communication.Grpc.AlarmLevelEnum.AlarmLevelNone) {
|
||||
size += 1 + pb::CodedOutputStream.ComputeEnumSize((int) Level);
|
||||
}
|
||||
if (Message.Length != 0) {
|
||||
size += 1 + pb::CodedOutputStream.ComputeStringSize(Message);
|
||||
}
|
||||
if (_unknownFields != null) {
|
||||
size += _unknownFields.CalculateSize();
|
||||
}
|
||||
@@ -1298,6 +1374,12 @@ namespace ScadaLink.Communication.Grpc {
|
||||
}
|
||||
Timestamp.MergeFrom(other.Timestamp);
|
||||
}
|
||||
if (other.Level != global::ScadaLink.Communication.Grpc.AlarmLevelEnum.AlarmLevelNone) {
|
||||
Level = other.Level;
|
||||
}
|
||||
if (other.Message.Length != 0) {
|
||||
Message = other.Message;
|
||||
}
|
||||
_unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
|
||||
}
|
||||
|
||||
@@ -1340,6 +1422,14 @@ namespace ScadaLink.Communication.Grpc {
|
||||
input.ReadMessage(Timestamp);
|
||||
break;
|
||||
}
|
||||
case 48: {
|
||||
Level = (global::ScadaLink.Communication.Grpc.AlarmLevelEnum) input.ReadEnum();
|
||||
break;
|
||||
}
|
||||
case 58: {
|
||||
Message = input.ReadString();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1382,6 +1472,14 @@ namespace ScadaLink.Communication.Grpc {
|
||||
input.ReadMessage(Timestamp);
|
||||
break;
|
||||
}
|
||||
case 48: {
|
||||
Level = (global::ScadaLink.Communication.Grpc.AlarmLevelEnum) input.ReadEnum();
|
||||
break;
|
||||
}
|
||||
case 58: {
|
||||
Message = input.ReadString();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// <auto-generated>
|
||||
// Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
// source: sitestream.proto
|
||||
// source: Protos/sitestream.proto
|
||||
// </auto-generated>
|
||||
#pragma warning disable 0414, 1591, 8981, 0612
|
||||
#region Designer generated code
|
||||
|
||||
@@ -41,6 +41,11 @@ public class InstanceConfiguration : IEntityTypeConfiguration<Instance>
|
||||
.HasForeignKey(o => o.InstanceId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(i => i.AlarmOverrides)
|
||||
.WithOne()
|
||||
.HasForeignKey(o => o.InstanceId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(i => i.ConnectionBindings)
|
||||
.WithOne()
|
||||
.HasForeignKey(b => b.InstanceId)
|
||||
@@ -67,6 +72,23 @@ public class InstanceAttributeOverrideConfiguration : IEntityTypeConfiguration<I
|
||||
}
|
||||
}
|
||||
|
||||
public class InstanceAlarmOverrideConfiguration : IEntityTypeConfiguration<InstanceAlarmOverride>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<InstanceAlarmOverride> builder)
|
||||
{
|
||||
builder.HasKey(o => o.Id);
|
||||
|
||||
builder.Property(o => o.AlarmCanonicalName)
|
||||
.IsRequired()
|
||||
.HasMaxLength(400); // Larger than attribute names to fit composed paths.
|
||||
|
||||
builder.Property(o => o.TriggerConfigurationOverride)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
builder.HasIndex(o => new { o.InstanceId, o.AlarmCanonicalName }).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
public class InstanceConnectionBindingConfiguration : IEntityTypeConfiguration<InstanceConnectionBinding>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<InstanceConnectionBinding> builder)
|
||||
|
||||
1342
src/ScadaLink.ConfigurationDatabase/Migrations/20260513055537_AddInstanceAlarmOverrides.Designer.cs
generated
Normal file
1342
src/ScadaLink.ConfigurationDatabase/Migrations/20260513055537_AddInstanceAlarmOverrides.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddInstanceAlarmOverrides : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "InstanceAlarmOverrides",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
InstanceId = table.Column<int>(type: "int", nullable: false),
|
||||
AlarmCanonicalName = table.Column<string>(type: "nvarchar(400)", maxLength: 400, nullable: false),
|
||||
TriggerConfigurationOverride = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true),
|
||||
PriorityLevelOverride = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_InstanceAlarmOverrides", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_InstanceAlarmOverrides_Instances_InstanceId",
|
||||
column: x => x.InstanceId,
|
||||
principalTable: "Instances",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_InstanceAlarmOverrides_InstanceId_AlarmCanonicalName",
|
||||
table: "InstanceAlarmOverrides",
|
||||
columns: new[] { "InstanceId", "AlarmCanonicalName" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "InstanceAlarmOverrides");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -478,6 +478,37 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||
b.ToTable("Instances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAlarmOverride", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("AlarmCanonicalName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(400)
|
||||
.HasColumnType("nvarchar(400)");
|
||||
|
||||
b.Property<int>("InstanceId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int?>("PriorityLevelOverride")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("TriggerConfigurationOverride")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("nvarchar(4000)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("InstanceId", "AlarmCanonicalName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("InstanceAlarmOverrides");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAttributeOverride", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -1144,6 +1175,15 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAlarmOverride", b =>
|
||||
{
|
||||
b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null)
|
||||
.WithMany("AlarmOverrides")
|
||||
.HasForeignKey("InstanceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAttributeOverride", b =>
|
||||
{
|
||||
b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null)
|
||||
@@ -1271,6 +1311,8 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||
|
||||
modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b =>
|
||||
{
|
||||
b.Navigation("AlarmOverrides");
|
||||
|
||||
b.Navigation("AttributeOverrides");
|
||||
|
||||
b.Navigation("ConnectionBindings");
|
||||
|
||||
@@ -169,6 +169,7 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
|
||||
{
|
||||
return await _dbContext.Set<Instance>()
|
||||
.Include(i => i.AttributeOverrides)
|
||||
.Include(i => i.AlarmOverrides)
|
||||
.Include(i => i.ConnectionBindings)
|
||||
.FirstOrDefaultAsync(i => i.Id == instanceId, cancellationToken);
|
||||
}
|
||||
@@ -177,6 +178,7 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
|
||||
{
|
||||
return await _dbContext.Set<Instance>()
|
||||
.Include(i => i.AttributeOverrides)
|
||||
.Include(i => i.AlarmOverrides)
|
||||
.Include(i => i.ConnectionBindings)
|
||||
.FirstOrDefaultAsync(i => i.UniqueName == uniqueName, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -222,6 +222,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
|
||||
{
|
||||
return await _context.Instances
|
||||
.Include(i => i.AttributeOverrides)
|
||||
.Include(i => i.AlarmOverrides)
|
||||
.Include(i => i.ConnectionBindings)
|
||||
.FirstOrDefaultAsync(i => i.Id == id, cancellationToken);
|
||||
}
|
||||
@@ -230,6 +231,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
|
||||
{
|
||||
return await _context.Instances
|
||||
.Include(i => i.AttributeOverrides)
|
||||
.Include(i => i.AlarmOverrides)
|
||||
.Include(i => i.ConnectionBindings)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
@@ -246,6 +248,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
|
||||
return await _context.Instances
|
||||
.Where(i => i.SiteId == siteId)
|
||||
.Include(i => i.AttributeOverrides)
|
||||
.Include(i => i.AlarmOverrides)
|
||||
.Include(i => i.ConnectionBindings)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
@@ -254,6 +257,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
|
||||
{
|
||||
return await _context.Instances
|
||||
.Include(i => i.AttributeOverrides)
|
||||
.Include(i => i.AlarmOverrides)
|
||||
.Include(i => i.ConnectionBindings)
|
||||
.FirstOrDefaultAsync(i => i.UniqueName == uniqueName, cancellationToken);
|
||||
}
|
||||
@@ -307,6 +311,43 @@ public class TemplateEngineRepository : ITemplateEngineRepository
|
||||
}
|
||||
}
|
||||
|
||||
// InstanceAlarmOverride
|
||||
|
||||
public async Task<IReadOnlyList<InstanceAlarmOverride>> GetAlarmOverridesByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.InstanceAlarmOverrides
|
||||
.Where(o => o.InstanceId == instanceId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<InstanceAlarmOverride?> GetAlarmOverrideAsync(int instanceId, string alarmCanonicalName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.InstanceAlarmOverrides
|
||||
.FirstOrDefaultAsync(
|
||||
o => o.InstanceId == instanceId && o.AlarmCanonicalName == alarmCanonicalName,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task AddInstanceAlarmOverrideAsync(InstanceAlarmOverride alarmOverride, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _context.InstanceAlarmOverrides.AddAsync(alarmOverride, cancellationToken);
|
||||
}
|
||||
|
||||
public Task UpdateInstanceAlarmOverrideAsync(InstanceAlarmOverride alarmOverride, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.InstanceAlarmOverrides.Update(alarmOverride);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DeleteInstanceAlarmOverrideAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var alarmOverride = await _context.InstanceAlarmOverrides.FindAsync(new object[] { id }, cancellationToken);
|
||||
if (alarmOverride != null)
|
||||
{
|
||||
_context.InstanceAlarmOverrides.Remove(alarmOverride);
|
||||
}
|
||||
}
|
||||
|
||||
// InstanceConnectionBinding
|
||||
|
||||
public async Task<IReadOnlyList<InstanceConnectionBinding>> GetBindingsByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -30,6 +30,7 @@ public class ScadaLinkDbContext : DbContext, IDataProtectionKeyContext
|
||||
// Instances
|
||||
public DbSet<Instance> Instances => Set<Instance>();
|
||||
public DbSet<InstanceAttributeOverride> InstanceAttributeOverrides => Set<InstanceAttributeOverride>();
|
||||
public DbSet<InstanceAlarmOverride> InstanceAlarmOverrides => Set<InstanceAlarmOverride>();
|
||||
public DbSet<InstanceConnectionBinding> InstanceConnectionBindings => Set<InstanceConnectionBinding>();
|
||||
public DbSet<Area> Areas => Set<Area>();
|
||||
|
||||
|
||||
@@ -26,17 +26,54 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/_framework/blazor.web.js"></script>
|
||||
<script src="/_framework/blazor.web.js"
|
||||
autostart="false"></script>
|
||||
<script>
|
||||
// Reconnection overlay for failover behavior
|
||||
// Blazor object is available after blazor.web.js initializes
|
||||
// Reconnection overlay for failover behavior. After a docker redeploy
|
||||
// (or other server-side restart), Blazor exhausts its retry budget and
|
||||
// leaves the user staring at a stuck "Reconnect failed" overlay. Auto-
|
||||
// reload in that case so the user lands on a fresh circuit instead of
|
||||
// having to manually refresh.
|
||||
Blazor.start({
|
||||
circuit: {
|
||||
reconnectionOptions: {
|
||||
maxRetries: 8,
|
||||
retryIntervalMilliseconds: 1500
|
||||
},
|
||||
reconnectionHandler: {
|
||||
onConnectionDown: () => { /* default overlay */ },
|
||||
onConnectionUp: () => {
|
||||
var m = document.getElementById('reconnect-modal');
|
||||
if (m) m.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (typeof Blazor !== 'undefined') {
|
||||
Blazor.addEventListener('enhancedload', () => {
|
||||
document.getElementById('reconnect-modal').style.display = 'none';
|
||||
Blazor.addEventListener?.('enhancedload', () => {
|
||||
var m = document.getElementById('reconnect-modal');
|
||||
if (m) m.style.display = 'none';
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// When Blazor gives up reconnecting, it adds the
|
||||
// `components-reconnect-failed` class to the reconnect modal element.
|
||||
// Watch for it and auto-reload so the user gets a fresh circuit.
|
||||
var mo = new MutationObserver(() => {
|
||||
var m = document.getElementById('reconnect-modal');
|
||||
if (!m) return;
|
||||
if (m.classList.contains('components-reconnect-failed')) {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
mo.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
subtree: true,
|
||||
attributeFilter: ['class']
|
||||
});
|
||||
</script>
|
||||
<script src="/js/treeview-storage.js"></script>
|
||||
<script src="_content/ScadaLink.CentralUI/js/monaco-init.js"></script>
|
||||
|
||||
@@ -119,6 +119,7 @@ public class ManagementActor : ReceiveActor
|
||||
CreateInstanceCommand or MgmtDeployInstanceCommand or MgmtEnableInstanceCommand
|
||||
or MgmtDisableInstanceCommand or MgmtDeleteInstanceCommand
|
||||
or SetConnectionBindingsCommand or SetInstanceOverridesCommand or SetInstanceAreaCommand
|
||||
or SetInstanceAlarmOverrideCommand or DeleteInstanceAlarmOverrideCommand
|
||||
or GetDeploymentDiffCommand
|
||||
or MgmtDeployArtifactsCommand
|
||||
or RetryParkedMessageCommand or DiscardParkedMessageCommand
|
||||
@@ -172,6 +173,9 @@ public class ManagementActor : ReceiveActor
|
||||
SetConnectionBindingsCommand cmd => await HandleSetConnectionBindings(sp, cmd, user),
|
||||
SetInstanceOverridesCommand cmd => await HandleSetInstanceOverrides(sp, cmd, user),
|
||||
SetInstanceAreaCommand cmd => await HandleSetInstanceArea(sp, cmd, user),
|
||||
SetInstanceAlarmOverrideCommand cmd => await HandleSetInstanceAlarmOverride(sp, cmd, user),
|
||||
DeleteInstanceAlarmOverrideCommand cmd => await HandleDeleteInstanceAlarmOverride(sp, cmd, user),
|
||||
ListInstanceAlarmOverridesCommand cmd => await HandleListInstanceAlarmOverrides(sp, cmd, user),
|
||||
|
||||
// Sites
|
||||
ListSitesCommand => await HandleListSites(sp, user),
|
||||
@@ -593,6 +597,37 @@ public class ManagementActor : ReceiveActor
|
||||
: throw new InvalidOperationException(result.Error);
|
||||
}
|
||||
|
||||
private static async Task<object?> HandleSetInstanceAlarmOverride(IServiceProvider sp, SetInstanceAlarmOverrideCommand cmd, AuthenticatedUser user)
|
||||
{
|
||||
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
|
||||
var svc = sp.GetRequiredService<InstanceService>();
|
||||
var result = await svc.SetAlarmOverrideAsync(
|
||||
cmd.InstanceId, cmd.AlarmCanonicalName,
|
||||
cmd.TriggerConfigurationOverride, cmd.PriorityLevelOverride,
|
||||
user.Username);
|
||||
return result.IsSuccess
|
||||
? result.Value
|
||||
: throw new InvalidOperationException(result.Error);
|
||||
}
|
||||
|
||||
private static async Task<object?> HandleDeleteInstanceAlarmOverride(IServiceProvider sp, DeleteInstanceAlarmOverrideCommand cmd, AuthenticatedUser user)
|
||||
{
|
||||
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
|
||||
var svc = sp.GetRequiredService<InstanceService>();
|
||||
var result = await svc.DeleteAlarmOverrideAsync(
|
||||
cmd.InstanceId, cmd.AlarmCanonicalName, user.Username);
|
||||
return result.IsSuccess
|
||||
? result.Value
|
||||
: throw new InvalidOperationException(result.Error);
|
||||
}
|
||||
|
||||
private static async Task<object?> HandleListInstanceAlarmOverrides(IServiceProvider sp, ListInstanceAlarmOverridesCommand cmd, AuthenticatedUser user)
|
||||
{
|
||||
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
|
||||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||||
return await repo.GetAlarmOverridesByInstanceIdAsync(cmd.InstanceId);
|
||||
}
|
||||
|
||||
private static async Task<object?> HandleGetDeploymentDiff(IServiceProvider sp, GetDeploymentDiffCommand cmd, AuthenticatedUser user)
|
||||
{
|
||||
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
|
||||
|
||||
@@ -38,6 +38,12 @@ public class AlarmActor : ReceiveActor
|
||||
private readonly ISiteHealthCollector? _healthCollector;
|
||||
|
||||
private AlarmState _currentState = AlarmState.Normal;
|
||||
/// <summary>
|
||||
/// Always <see cref="AlarmLevel.None"/> for binary trigger types. For
|
||||
/// <see cref="AlarmTriggerType.HiLo"/> this is the source of truth — the
|
||||
/// state machine transitions when the computed level changes.
|
||||
/// </summary>
|
||||
private AlarmLevel _currentLevel = AlarmLevel.None;
|
||||
private readonly AlarmTriggerType _triggerType;
|
||||
private readonly AlarmEvalConfig _evalConfig;
|
||||
private readonly int _priority;
|
||||
@@ -126,6 +132,12 @@ public class AlarmActor : ReceiveActor
|
||||
|
||||
try
|
||||
{
|
||||
if (_triggerType == AlarmTriggerType.HiLo)
|
||||
{
|
||||
HandleHiLoTransition(EvaluateHiLo(changed.Value));
|
||||
return;
|
||||
}
|
||||
|
||||
var isTriggered = _triggerType switch
|
||||
{
|
||||
AlarmTriggerType.ValueMatch => EvaluateValueMatch(changed.Value),
|
||||
@@ -150,7 +162,7 @@ public class AlarmActor : ReceiveActor
|
||||
// Spawn AlarmExecutionActor if on-trigger script defined
|
||||
if (_onTriggerCompiledScript != null)
|
||||
{
|
||||
SpawnAlarmExecution();
|
||||
SpawnAlarmExecution(AlarmLevel.None, _priority, string.Empty);
|
||||
}
|
||||
}
|
||||
else if (!isTriggered && _currentState == AlarmState.Active)
|
||||
@@ -176,6 +188,78 @@ public class AlarmActor : ReceiveActor
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HiLo state machine: emit an AlarmStateChanged whenever the evaluated
|
||||
/// level changes. Spawns the on-trigger script only on the Normal→Active
|
||||
/// edge (i.e., when entering an alarm band from the normal band) — not on
|
||||
/// level escalations like Hi→HiHi or Low→LowLow.
|
||||
/// </summary>
|
||||
private void HandleHiLoTransition(AlarmLevel newLevel)
|
||||
{
|
||||
if (newLevel == _currentLevel) return;
|
||||
|
||||
var previousLevel = _currentLevel;
|
||||
_currentLevel = newLevel;
|
||||
_currentState = newLevel == AlarmLevel.None ? AlarmState.Normal : AlarmState.Active;
|
||||
var priority = LevelPriority(newLevel);
|
||||
var message = LevelMessage(newLevel);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Alarm {Alarm} on {Instance} transitioned {Prev} → {New} (priority={Priority})",
|
||||
_alarmName, _instanceName, previousLevel, newLevel, priority);
|
||||
|
||||
var alarmChanged = new AlarmStateChanged(
|
||||
_instanceName, _alarmName, _currentState, priority, DateTimeOffset.UtcNow)
|
||||
{
|
||||
Level = newLevel,
|
||||
Message = message
|
||||
};
|
||||
_instanceActor.Tell(alarmChanged);
|
||||
|
||||
if (previousLevel == AlarmLevel.None
|
||||
&& newLevel != AlarmLevel.None
|
||||
&& _onTriggerCompiledScript != null)
|
||||
{
|
||||
SpawnAlarmExecution(newLevel, priority, message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the per-setpoint priority for the given level. Falls back to
|
||||
/// the alarm-level <see cref="_priority"/> when the HiLo config did not
|
||||
/// override the priority for that band, or for <see cref="AlarmLevel.None"/>.
|
||||
/// </summary>
|
||||
private int LevelPriority(AlarmLevel level)
|
||||
{
|
||||
if (_evalConfig is not HiLoEvalConfig hiLo) return _priority;
|
||||
return level switch
|
||||
{
|
||||
AlarmLevel.LowLow => hiLo.LoLoPriority ?? _priority,
|
||||
AlarmLevel.Low => hiLo.LoPriority ?? _priority,
|
||||
AlarmLevel.High => hiLo.HiPriority ?? _priority,
|
||||
AlarmLevel.HighHigh => hiLo.HiHiPriority ?? _priority,
|
||||
_ => _priority
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-band operator message. Empty string when no message is configured
|
||||
/// for the band, or for non-HiLo trigger types, or for the None level
|
||||
/// (alarm clear).
|
||||
/// </summary>
|
||||
private string LevelMessage(AlarmLevel level)
|
||||
{
|
||||
if (_evalConfig is not HiLoEvalConfig hiLo) return string.Empty;
|
||||
return level switch
|
||||
{
|
||||
AlarmLevel.LowLow => hiLo.LoLoMessage ?? string.Empty,
|
||||
AlarmLevel.Low => hiLo.LoMessage ?? string.Empty,
|
||||
AlarmLevel.High => hiLo.HiMessage ?? string.Empty,
|
||||
AlarmLevel.HighHigh => hiLo.HiHiMessage ?? string.Empty,
|
||||
_ => string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private bool IsMonitoredAttribute(string attributeName)
|
||||
{
|
||||
return _evalConfig.MonitoredAttributeName == attributeName;
|
||||
@@ -254,9 +338,57 @@ public class AlarmActor : ReceiveActor
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spawns an AlarmExecutionActor to run the on-trigger script.
|
||||
/// HiLo level evaluator: returns the most-severe matching band for the
|
||||
/// given value. Severity order checked from highest to lowest so that a
|
||||
/// value at exactly Hi==HiHi resolves to HighHigh. Unset setpoints (null)
|
||||
/// are skipped, allowing partial configs (e.g., HighHigh only).
|
||||
///
|
||||
/// Hysteresis: when the alarm is already in a level whose threshold the
|
||||
/// value would re-cross from inside, the threshold is relaxed by the
|
||||
/// configured deadband. This prevents flapping at the boundary — once at
|
||||
/// HighHigh with HiHi=100 and hiHiDeadband=5, the alarm stays HighHigh
|
||||
/// until the value drops below 95.
|
||||
/// </summary>
|
||||
private void SpawnAlarmExecution()
|
||||
private AlarmLevel EvaluateHiLo(object? value)
|
||||
{
|
||||
if (_evalConfig is not HiLoEvalConfig config) return AlarmLevel.None;
|
||||
if (value == null) return _currentLevel;
|
||||
|
||||
double numericValue;
|
||||
try { numericValue = Convert.ToDouble(value); }
|
||||
catch { return _currentLevel; }
|
||||
|
||||
// When the current level is at-or-above HighHigh, relax the HiHi exit.
|
||||
// Same for the other directions.
|
||||
var hiHiThreshold = config.HiHi;
|
||||
if (hiHiThreshold is { } hh && _currentLevel == AlarmLevel.HighHigh)
|
||||
hiHiThreshold = hh - Math.Max(0, config.HiHiDeadband ?? 0);
|
||||
|
||||
var hiThreshold = config.Hi;
|
||||
if (hiThreshold is { } h && (_currentLevel == AlarmLevel.High || _currentLevel == AlarmLevel.HighHigh))
|
||||
hiThreshold = h - Math.Max(0, config.HiDeadband ?? 0);
|
||||
|
||||
var loLoThreshold = config.LoLo;
|
||||
if (loLoThreshold is { } ll && _currentLevel == AlarmLevel.LowLow)
|
||||
loLoThreshold = ll + Math.Max(0, config.LoLoDeadband ?? 0);
|
||||
|
||||
var loThreshold = config.Lo;
|
||||
if (loThreshold is { } l && (_currentLevel == AlarmLevel.Low || _currentLevel == AlarmLevel.LowLow))
|
||||
loThreshold = l + Math.Max(0, config.LoDeadband ?? 0);
|
||||
|
||||
if (hiHiThreshold is { } effHiHi && numericValue >= effHiHi) return AlarmLevel.HighHigh;
|
||||
if (hiThreshold is { } effHi && numericValue >= effHi) return AlarmLevel.High;
|
||||
if (loLoThreshold is { } effLoLo && numericValue <= effLoLo) return AlarmLevel.LowLow;
|
||||
if (loThreshold is { } effLo && numericValue <= effLo) return AlarmLevel.Low;
|
||||
return AlarmLevel.None;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spawns an AlarmExecutionActor to run the on-trigger script.
|
||||
/// Passes the firing alarm's level/priority/message so the script can
|
||||
/// branch on severity via the <c>Alarm</c> global.
|
||||
/// </summary>
|
||||
private void SpawnAlarmExecution(AlarmLevel level, int priority, string message)
|
||||
{
|
||||
if (_onTriggerCompiledScript == null) return;
|
||||
|
||||
@@ -266,6 +398,9 @@ public class AlarmActor : ReceiveActor
|
||||
var props = Props.Create(() => new AlarmExecutionActor(
|
||||
_alarmName,
|
||||
_instanceName,
|
||||
level,
|
||||
priority,
|
||||
message,
|
||||
_onTriggerCompiledScript,
|
||||
_instanceActor,
|
||||
_sharedScriptLibrary,
|
||||
@@ -319,6 +454,25 @@ public class AlarmActor : ReceiveActor
|
||||
? ParseDirection(dirEl.GetString())
|
||||
: RateOfChangeDirection.Either),
|
||||
|
||||
AlarmTriggerType.HiLo => new HiLoEvalConfig(
|
||||
attr,
|
||||
LoLo: TryReadDouble(root, "loLo"),
|
||||
Lo: TryReadDouble(root, "lo"),
|
||||
Hi: TryReadDouble(root, "hi"),
|
||||
HiHi: TryReadDouble(root, "hiHi"),
|
||||
LoLoPriority: TryReadInt(root, "loLoPriority"),
|
||||
LoPriority: TryReadInt(root, "loPriority"),
|
||||
HiPriority: TryReadInt(root, "hiPriority"),
|
||||
HiHiPriority: TryReadInt(root, "hiHiPriority"),
|
||||
LoLoDeadband: TryReadDouble(root, "loLoDeadband"),
|
||||
LoDeadband: TryReadDouble(root, "loDeadband"),
|
||||
HiDeadband: TryReadDouble(root, "hiDeadband"),
|
||||
HiHiDeadband: TryReadDouble(root, "hiHiDeadband"),
|
||||
LoLoMessage: TryReadString(root, "loLoMessage"),
|
||||
LoMessage: TryReadString(root, "loMessage"),
|
||||
HiMessage: TryReadString(root, "hiMessage"),
|
||||
HiHiMessage: TryReadString(root, "hiHiMessage")),
|
||||
|
||||
_ => new ValueMatchEvalConfig(attr, null)
|
||||
};
|
||||
}
|
||||
@@ -336,6 +490,35 @@ public class AlarmActor : ReceiveActor
|
||||
_ => RateOfChangeDirection.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(), System.Globalization.NumberStyles.Float, System.Globalization.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(), System.Globalization.NumberStyles.Integer, System.Globalization.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 messages ──
|
||||
internal record AlarmExecutionCompleted(string AlarmName, bool Success);
|
||||
}
|
||||
@@ -351,3 +534,27 @@ internal record RateOfChangeEvalConfig(
|
||||
double ThresholdPerSecond,
|
||||
TimeSpan WindowDuration,
|
||||
RateOfChangeDirection Direction) : AlarmEvalConfig(MonitoredAttributeName);
|
||||
|
||||
/// <summary>
|
||||
/// HiLo evaluation config: any subset of the four setpoints may be set; null
|
||||
/// means "don't evaluate that band". Per-setpoint priorities override the
|
||||
/// alarm-level priority for AlarmStateChanged messages emitted for that band.
|
||||
/// </summary>
|
||||
internal record HiLoEvalConfig(
|
||||
string MonitoredAttributeName,
|
||||
double? LoLo,
|
||||
double? Lo,
|
||||
double? Hi,
|
||||
double? HiHi,
|
||||
int? LoLoPriority,
|
||||
int? LoPriority,
|
||||
int? HiPriority,
|
||||
int? HiHiPriority,
|
||||
double? LoLoDeadband = null,
|
||||
double? LoDeadband = null,
|
||||
double? HiDeadband = null,
|
||||
double? HiHiDeadband = null,
|
||||
string? LoLoMessage = null,
|
||||
string? LoMessage = null,
|
||||
string? HiMessage = null,
|
||||
string? HiHiMessage = null) : AlarmEvalConfig(MonitoredAttributeName);
|
||||
|
||||
@@ -2,6 +2,8 @@ using Akka.Actor;
|
||||
using Microsoft.CodeAnalysis.Scripting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ScadaLink.Commons.Types;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.Commons.Types.Scripts;
|
||||
using ScadaLink.SiteRuntime.Scripts;
|
||||
|
||||
namespace ScadaLink.SiteRuntime.Actors;
|
||||
@@ -18,6 +20,9 @@ public class AlarmExecutionActor : ReceiveActor
|
||||
public AlarmExecutionActor(
|
||||
string alarmName,
|
||||
string instanceName,
|
||||
AlarmLevel level,
|
||||
int priority,
|
||||
string message,
|
||||
Script<object?> compiledScript,
|
||||
IActorRef instanceActor,
|
||||
SharedScriptLibrary sharedScriptLibrary,
|
||||
@@ -28,13 +33,17 @@ public class AlarmExecutionActor : ReceiveActor
|
||||
var parent = Context.Parent;
|
||||
|
||||
ExecuteAlarmScript(
|
||||
alarmName, instanceName, compiledScript, instanceActor,
|
||||
alarmName, instanceName, level, priority, message,
|
||||
compiledScript, instanceActor,
|
||||
sharedScriptLibrary, options, self, parent, logger);
|
||||
}
|
||||
|
||||
private static void ExecuteAlarmScript(
|
||||
string alarmName,
|
||||
string instanceName,
|
||||
AlarmLevel level,
|
||||
int priority,
|
||||
string message,
|
||||
Script<object?> compiledScript,
|
||||
IActorRef instanceActor,
|
||||
SharedScriptLibrary sharedScriptLibrary,
|
||||
@@ -66,7 +75,14 @@ public class AlarmExecutionActor : ReceiveActor
|
||||
{
|
||||
Instance = context,
|
||||
Parameters = new ScriptParameters(),
|
||||
CancellationToken = cts.Token
|
||||
CancellationToken = cts.Token,
|
||||
Alarm = new AlarmContext
|
||||
{
|
||||
Name = alarmName,
|
||||
Level = level,
|
||||
Priority = priority,
|
||||
Message = message
|
||||
}
|
||||
};
|
||||
|
||||
await compiledScript.RunAsync(globals, cts.Token);
|
||||
|
||||
@@ -181,6 +181,14 @@ public class ScriptGlobals
|
||||
public ScriptParameters Parameters { get; set; } = new ScriptParameters();
|
||||
public CancellationToken CancellationToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Alarm context when this script is invoked as an on-trigger handler.
|
||||
/// Null for instance scripts, shared scripts, and inbound-API-routed
|
||||
/// scripts. Lets on-trigger scripts read the firing alarm's Name, Level
|
||||
/// (HiLo only), Priority, and per-band Message to branch routing logic.
|
||||
/// </summary>
|
||||
public Commons.Types.Scripts.AlarmContext? Alarm { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Where this script sits in the composition tree. Defaults to root for
|
||||
/// scripts on top-level templates; a flattened composed script gets
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using ScadaLink.Commons.Entities.Instances;
|
||||
using ScadaLink.Commons.Entities.Sites;
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
@@ -80,6 +81,7 @@ public class FlatteningService
|
||||
// Step 5: Resolve alarms from inheritance chain
|
||||
var alarms = ResolveInheritedAlarms(templateChain);
|
||||
ResolveComposedAlarms(templateChain, compositionMap, composedTemplateChains, alarms);
|
||||
ApplyInstanceAlarmOverrides(instance.AlarmOverrides, alarms);
|
||||
|
||||
// Step 6: Resolve scripts from inheritance chain
|
||||
var scripts = ResolveInheritedScripts(templateChain);
|
||||
@@ -292,6 +294,43 @@ public class FlatteningService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies per-instance alarm overrides on top of the
|
||||
/// inheritance-and-composition resolved alarms. Skips overrides for
|
||||
/// alarms that are locked at the template level. For HiLo triggers the
|
||||
/// override JSON is merged setpoint-by-setpoint (preserving inherited
|
||||
/// keys not present in the override); for other trigger types the
|
||||
/// override replaces the whole TriggerConfiguration.
|
||||
/// </summary>
|
||||
private static void ApplyInstanceAlarmOverrides(
|
||||
ICollection<InstanceAlarmOverride> overrides,
|
||||
Dictionary<string, ResolvedAlarm> alarms)
|
||||
{
|
||||
foreach (var ovr in overrides)
|
||||
{
|
||||
if (!alarms.TryGetValue(ovr.AlarmCanonicalName, out var existing))
|
||||
continue; // Cannot add new alarms via overrides
|
||||
|
||||
if (existing.IsLocked)
|
||||
continue; // Locked alarms cannot be overridden
|
||||
|
||||
var newConfig = existing.TriggerConfiguration;
|
||||
if (!string.IsNullOrWhiteSpace(ovr.TriggerConfigurationOverride))
|
||||
{
|
||||
newConfig = existing.TriggerType == nameof(AlarmTriggerType.HiLo)
|
||||
? MergeHiLoConfig(existing.TriggerConfiguration, ovr.TriggerConfigurationOverride)
|
||||
: ovr.TriggerConfigurationOverride;
|
||||
}
|
||||
|
||||
alarms[ovr.AlarmCanonicalName] = existing with
|
||||
{
|
||||
TriggerConfiguration = newConfig,
|
||||
PriorityLevel = ovr.PriorityLevelOverride ?? existing.PriorityLevel,
|
||||
Source = "Override"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyConnectionBindings(
|
||||
ICollection<InstanceConnectionBinding> bindings,
|
||||
Dictionary<string, ResolvedAttribute> attributes,
|
||||
@@ -332,6 +371,18 @@ public class FlatteningService
|
||||
if (result.TryGetValue(alarm.Name, out var existing) && existing.IsLocked)
|
||||
continue;
|
||||
|
||||
// HiLo per-setpoint override: derived templates can supply a
|
||||
// partial TriggerConfiguration (e.g., just `hi`) and have the
|
||||
// remaining setpoints inherited. Other trigger types replace
|
||||
// the whole config on override (current behavior).
|
||||
var triggerConfig = alarm.TriggerConfiguration;
|
||||
if (existing != null
|
||||
&& alarm.TriggerType == AlarmTriggerType.HiLo
|
||||
&& existing.TriggerType == nameof(AlarmTriggerType.HiLo))
|
||||
{
|
||||
triggerConfig = MergeHiLoConfig(existing.TriggerConfiguration, triggerConfig);
|
||||
}
|
||||
|
||||
result[alarm.Name] = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = alarm.Name,
|
||||
@@ -339,7 +390,7 @@ public class FlatteningService
|
||||
PriorityLevel = alarm.PriorityLevel,
|
||||
IsLocked = alarm.IsLocked,
|
||||
TriggerType = alarm.TriggerType.ToString(),
|
||||
TriggerConfiguration = alarm.TriggerConfiguration,
|
||||
TriggerConfiguration = triggerConfig,
|
||||
OnTriggerScriptCanonicalName = null, // Resolved later
|
||||
Source = source
|
||||
};
|
||||
@@ -349,6 +400,61 @@ public class FlatteningService
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges a derived HiLo trigger configuration onto an inherited one.
|
||||
/// Top-level keys present in <paramref name="derivedJson"/> override the
|
||||
/// inherited values; keys absent in the derived config are inherited.
|
||||
/// Returns the derived config verbatim on parse failure of either input —
|
||||
/// the existing whole-replace behavior is the safe fallback.
|
||||
/// </summary>
|
||||
internal static string? MergeHiLoConfig(string? inheritedJson, string? derivedJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(inheritedJson)) return derivedJson;
|
||||
if (string.IsNullOrWhiteSpace(derivedJson)) return inheritedJson;
|
||||
|
||||
try
|
||||
{
|
||||
using var inheritedDoc = JsonDocument.Parse(inheritedJson);
|
||||
using var derivedDoc = JsonDocument.Parse(derivedJson);
|
||||
|
||||
if (inheritedDoc.RootElement.ValueKind != JsonValueKind.Object
|
||||
|| derivedDoc.RootElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return derivedJson;
|
||||
}
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
using (var writer = new Utf8JsonWriter(stream))
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
|
||||
var derivedKeys = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var prop in derivedDoc.RootElement.EnumerateObject())
|
||||
derivedKeys.Add(prop.Name);
|
||||
|
||||
// Inherited keys not present in derived survive.
|
||||
foreach (var prop in inheritedDoc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (derivedKeys.Contains(prop.Name)) continue;
|
||||
prop.WriteTo(writer);
|
||||
}
|
||||
|
||||
// Derived keys win.
|
||||
foreach (var prop in derivedDoc.RootElement.EnumerateObject())
|
||||
{
|
||||
prop.WriteTo(writer);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
return System.Text.Encoding.UTF8.GetString(stream.ToArray());
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return derivedJson;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ResolveComposedAlarms(
|
||||
IReadOnlyList<Template> templateChain,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<TemplateComposition>> compositionMap,
|
||||
|
||||
@@ -169,6 +169,94 @@ public class InstanceService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a per-instance alarm override. The alarm must exist on the
|
||||
/// template and must not be locked. For HiLo alarms, the override JSON
|
||||
/// merges into the inherited TriggerConfiguration setpoint-by-setpoint;
|
||||
/// for binary trigger types, it replaces the whole config.
|
||||
/// </summary>
|
||||
public async Task<Result<InstanceAlarmOverride>> SetAlarmOverrideAsync(
|
||||
int instanceId,
|
||||
string alarmCanonicalName,
|
||||
string? triggerConfigurationOverride,
|
||||
int? priorityLevelOverride,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
if (instance == null)
|
||||
return Result<InstanceAlarmOverride>.Failure($"Instance with ID {instanceId} not found.");
|
||||
|
||||
// Verify alarm exists in the template and is not locked. Only direct
|
||||
// template alarms are checked here — composed-member overrides go
|
||||
// through but are silently ignored at runtime if the name doesn't
|
||||
// match (same behavior as attribute overrides).
|
||||
var templateAlarms = await _repository.GetAlarmsByTemplateIdAsync(instance.TemplateId, cancellationToken);
|
||||
var templateAlarm = templateAlarms.FirstOrDefault(a => a.Name == alarmCanonicalName);
|
||||
if (templateAlarm != null && templateAlarm.IsLocked)
|
||||
{
|
||||
return Result<InstanceAlarmOverride>.Failure(
|
||||
$"Alarm '{alarmCanonicalName}' is locked and cannot be overridden.");
|
||||
}
|
||||
|
||||
var existingOverride = await _repository.GetAlarmOverrideAsync(
|
||||
instanceId, alarmCanonicalName, cancellationToken);
|
||||
|
||||
if (existingOverride != null)
|
||||
{
|
||||
existingOverride.TriggerConfigurationOverride = triggerConfigurationOverride;
|
||||
existingOverride.PriorityLevelOverride = priorityLevelOverride;
|
||||
await _repository.UpdateInstanceAlarmOverrideAsync(existingOverride, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "UpdateAlarmOverride", "InstanceAlarmOverride",
|
||||
existingOverride.Id.ToString(), alarmCanonicalName, existingOverride, cancellationToken);
|
||||
|
||||
return Result<InstanceAlarmOverride>.Success(existingOverride);
|
||||
}
|
||||
else
|
||||
{
|
||||
var newOverride = new InstanceAlarmOverride(alarmCanonicalName)
|
||||
{
|
||||
InstanceId = instanceId,
|
||||
TriggerConfigurationOverride = triggerConfigurationOverride,
|
||||
PriorityLevelOverride = priorityLevelOverride
|
||||
};
|
||||
|
||||
await _repository.AddInstanceAlarmOverrideAsync(newOverride, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "CreateAlarmOverride", "InstanceAlarmOverride",
|
||||
newOverride.Id.ToString(), alarmCanonicalName, newOverride, cancellationToken);
|
||||
|
||||
return Result<InstanceAlarmOverride>.Success(newOverride);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a per-instance alarm override. After removal the instance
|
||||
/// inherits the template alarm config unchanged.
|
||||
/// </summary>
|
||||
public async Task<Result<bool>> DeleteAlarmOverrideAsync(
|
||||
int instanceId,
|
||||
string alarmCanonicalName,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await _repository.GetAlarmOverrideAsync(
|
||||
instanceId, alarmCanonicalName, cancellationToken);
|
||||
if (existing == null)
|
||||
return Result<bool>.Failure($"No alarm override for '{alarmCanonicalName}' on instance {instanceId}.");
|
||||
|
||||
await _repository.DeleteInstanceAlarmOverrideAsync(existing.Id, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "DeleteAlarmOverride", "InstanceAlarmOverride",
|
||||
existing.Id.ToString(), alarmCanonicalName, existing, cancellationToken);
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets connection bindings for an instance in bulk.
|
||||
/// </summary>
|
||||
|
||||
@@ -136,6 +136,75 @@ public class SemanticValidator
|
||||
}
|
||||
}
|
||||
|
||||
// HiLo requires numeric attribute + ordered setpoints
|
||||
if (alarm.TriggerType == "HiLo" && !string.IsNullOrWhiteSpace(alarm.TriggerConfiguration))
|
||||
{
|
||||
var attrName = ValidationService.ExtractAttributeNameFromTriggerConfig(alarm.TriggerConfiguration);
|
||||
if (attrName != null && attributeMap.TryGetValue(attrName, out var attr))
|
||||
{
|
||||
if (!NumericDataTypes.Contains(attr.DataType))
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
|
||||
$"Alarm '{alarm.CanonicalName}' uses HiLo trigger on attribute '{attrName}' which has non-numeric type '{attr.DataType}'.",
|
||||
alarm.CanonicalName));
|
||||
}
|
||||
}
|
||||
|
||||
var setpoints = ValidationService.ExtractHiLoSetpoints(alarm.TriggerConfiguration);
|
||||
|
||||
// At least one setpoint must be configured — otherwise the alarm
|
||||
// can never fire.
|
||||
if (!setpoints.LoLo.HasValue && !setpoints.Lo.HasValue
|
||||
&& !setpoints.Hi.HasValue && !setpoints.HiHi.HasValue)
|
||||
{
|
||||
warnings.Add(ValidationEntry.Warning(ValidationCategory.TriggerOperandType,
|
||||
$"Alarm '{alarm.CanonicalName}' is HiLo but no setpoints (LoLo/Lo/Hi/HiHi) are configured — it will never fire.",
|
||||
alarm.CanonicalName));
|
||||
}
|
||||
|
||||
// Ordering: LoLo ≤ Lo, Hi ≤ HiHi, and the highest Lo-side band
|
||||
// must sit strictly below the lowest Hi-side band — otherwise the
|
||||
// bands overlap and the evaluator's behavior is ambiguous.
|
||||
if (setpoints.LoLo is { } loLo && setpoints.Lo is { } lo && loLo > lo)
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
|
||||
$"Alarm '{alarm.CanonicalName}' HiLo setpoints out of order: LoLo ({loLo}) must be ≤ Lo ({lo}).",
|
||||
alarm.CanonicalName));
|
||||
}
|
||||
if (setpoints.Hi is { } hi && setpoints.HiHi is { } hiHi && hi > hiHi)
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
|
||||
$"Alarm '{alarm.CanonicalName}' HiLo setpoints out of order: Hi ({hi}) must be ≤ HiHi ({hiHi}).",
|
||||
alarm.CanonicalName));
|
||||
}
|
||||
var highestLowSide = setpoints.Lo ?? setpoints.LoLo;
|
||||
var lowestHighSide = setpoints.Hi ?? setpoints.HiHi;
|
||||
if (highestLowSide is { } lowSide && lowestHighSide is { } highSide
|
||||
&& lowSide >= highSide)
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
|
||||
$"Alarm '{alarm.CanonicalName}' HiLo bands overlap: low-side setpoint ({lowSide}) must be strictly less than high-side setpoint ({highSide}).",
|
||||
alarm.CanonicalName));
|
||||
}
|
||||
|
||||
// Deadbands must be non-negative — negative deadband would invert
|
||||
// the hysteresis (alarm could escape faster than it entered).
|
||||
foreach (var (name, value) in new (string, double?)[] {
|
||||
("LoLo deadband", setpoints.LoLoDeadband),
|
||||
("Lo deadband", setpoints.LoDeadband),
|
||||
("Hi deadband", setpoints.HiDeadband),
|
||||
("HiHi deadband", setpoints.HiHiDeadband)
|
||||
})
|
||||
{
|
||||
if (value is { } d && d < 0)
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
|
||||
$"Alarm '{alarm.CanonicalName}' {name} ({d}) must be non-negative.",
|
||||
alarm.CanonicalName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// On-trigger script must exist
|
||||
if (!string.IsNullOrWhiteSpace(alarm.OnTriggerScriptCanonicalName) &&
|
||||
!scriptNames.Contains(alarm.OnTriggerScriptCanonicalName))
|
||||
|
||||
@@ -232,4 +232,50 @@ public class ValidationService
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the four HiLo setpoints from a trigger configuration JSON.
|
||||
/// Any unset (or non-numeric) setpoint comes back as <c>null</c>. Returns
|
||||
/// all-nulls on malformed JSON — callers should treat that as "nothing to
|
||||
/// validate" and let other checks surface the deeper problem.
|
||||
/// </summary>
|
||||
internal static HiLoSetpoints ExtractHiLoSetpoints(string triggerConfigJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(triggerConfigJson);
|
||||
var root = doc.RootElement;
|
||||
return new HiLoSetpoints(
|
||||
LoLo: ReadDouble(root, "loLo"),
|
||||
Lo: ReadDouble(root, "lo"),
|
||||
Hi: ReadDouble(root, "hi"),
|
||||
HiHi: ReadDouble(root, "hiHi"),
|
||||
LoLoDeadband: ReadDouble(root, "loLoDeadband"),
|
||||
LoDeadband: ReadDouble(root, "loDeadband"),
|
||||
HiDeadband: ReadDouble(root, "hiDeadband"),
|
||||
HiHiDeadband: ReadDouble(root, "hiHiDeadband"));
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new HiLoSetpoints(null, null, null, null, null, null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static double? ReadDouble(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(),
|
||||
System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var v) => v,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly record struct HiLoSetpoints(
|
||||
double? LoLo, double? Lo, double? Hi, double? HiHi,
|
||||
double? LoLoDeadband = null, double? LoDeadband = null,
|
||||
double? HiDeadband = null, double? HiHiDeadband = null);
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
using System.Text.Json;
|
||||
using ScadaLink.CentralUI.Components.Shared;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
|
||||
namespace ScadaLink.CentralUI.Tests.Shared;
|
||||
|
||||
public class AlarmTriggerConfigCodecTests
|
||||
{
|
||||
// ── Parse: ValueMatch ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Parse_ValueMatch_ReadsCanonicalKeys()
|
||||
{
|
||||
const string json = @"{""attributeName"":""Status"",""matchValue"":""Critical""}";
|
||||
|
||||
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.ValueMatch);
|
||||
|
||||
Assert.Equal("Status", model.AttributeName);
|
||||
Assert.Equal("Critical", model.MatchValue);
|
||||
Assert.False(model.NotEquals);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ValueMatch_AcceptsLegacyAttributeAndValueKeys()
|
||||
{
|
||||
// Older configs used "attribute" and "value" instead of the canonical names.
|
||||
const string json = @"{""attribute"":""Status"",""value"":""Critical""}";
|
||||
|
||||
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.ValueMatch);
|
||||
|
||||
Assert.Equal("Status", model.AttributeName);
|
||||
Assert.Equal("Critical", model.MatchValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ValueMatch_NotEqualsPrefix_SetsFlagAndStripsPrefix()
|
||||
{
|
||||
const string json = @"{""attributeName"":""Status"",""matchValue"":""!=Good""}";
|
||||
|
||||
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.ValueMatch);
|
||||
|
||||
Assert.True(model.NotEquals);
|
||||
Assert.Equal("Good", model.MatchValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ValueMatch_MissingMatchValue_LeavesNull()
|
||||
{
|
||||
const string json = @"{""attributeName"":""Status""}";
|
||||
|
||||
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.ValueMatch);
|
||||
|
||||
Assert.Equal("Status", model.AttributeName);
|
||||
Assert.Null(model.MatchValue);
|
||||
Assert.False(model.NotEquals);
|
||||
}
|
||||
|
||||
// ── Parse: RangeViolation ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Parse_RangeViolation_ReadsCanonicalKeys()
|
||||
{
|
||||
const string json = @"{""attributeName"":""Temp"",""min"":0,""max"":100}";
|
||||
|
||||
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.RangeViolation);
|
||||
|
||||
Assert.Equal(0, model.Min);
|
||||
Assert.Equal(100, model.Max);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_RangeViolation_AcceptsLegacyLowHighKeys()
|
||||
{
|
||||
const string json = @"{""attributeName"":""Temp"",""low"":-10,""high"":50}";
|
||||
|
||||
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.RangeViolation);
|
||||
|
||||
Assert.Equal(-10, model.Min);
|
||||
Assert.Equal(50, model.Max);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_RangeViolation_CanonicalKeysWinOverLegacy()
|
||||
{
|
||||
// If both canonical and legacy aliases are present, the canonical key wins.
|
||||
const string json = @"{""attributeName"":""T"",""min"":0,""low"":-999,""max"":100,""high"":999}";
|
||||
|
||||
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.RangeViolation);
|
||||
|
||||
Assert.Equal(0, model.Min);
|
||||
Assert.Equal(100, model.Max);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_RangeViolation_StringNumericValues_AreParsed()
|
||||
{
|
||||
// Some configs serialize min/max as JSON strings. Codec accepts both.
|
||||
const string json = @"{""attributeName"":""T"",""min"":""1.5"",""max"":""9.75""}";
|
||||
|
||||
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.RangeViolation);
|
||||
|
||||
Assert.Equal(1.5, model.Min);
|
||||
Assert.Equal(9.75, model.Max);
|
||||
}
|
||||
|
||||
// ── Parse: RateOfChange ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Parse_RateOfChange_ReadsAllFields()
|
||||
{
|
||||
const string json = @"{""attributeName"":""Pressure"",""thresholdPerSecond"":25,""windowSeconds"":2,""direction"":""rising""}";
|
||||
|
||||
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.RateOfChange);
|
||||
|
||||
Assert.Equal("Pressure", model.AttributeName);
|
||||
Assert.Equal(25, model.ThresholdPerSecond);
|
||||
Assert.Equal(2, model.WindowSeconds);
|
||||
Assert.Equal("rising", model.Direction);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("rising", "rising")]
|
||||
[InlineData("Rising", "rising")]
|
||||
[InlineData("up", "rising")]
|
||||
[InlineData("positive", "rising")]
|
||||
[InlineData("falling", "falling")]
|
||||
[InlineData("Down", "falling")]
|
||||
[InlineData("negative", "falling")]
|
||||
[InlineData("either", "either")]
|
||||
[InlineData("bogus", "either")]
|
||||
[InlineData("", "either")]
|
||||
public void Parse_RateOfChange_NormalizesDirectionAliases(string input, string expected)
|
||||
{
|
||||
var json = $@"{{""attributeName"":""x"",""direction"":""{input}""}}";
|
||||
|
||||
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.RateOfChange);
|
||||
|
||||
Assert.Equal(expected, model.Direction);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_RateOfChange_MissingDirection_DefaultsToEither()
|
||||
{
|
||||
// Older configs predate the direction field — the codec must default it
|
||||
// so existing data round-trips without surprises.
|
||||
const string json = @"{""attributeName"":""x"",""thresholdPerSecond"":10,""windowSeconds"":1}";
|
||||
|
||||
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.RateOfChange);
|
||||
|
||||
Assert.Equal("either", model.Direction);
|
||||
}
|
||||
|
||||
// ── Parse: misc ────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Parse_NullOrWhitespace_ReturnsDefaultModel(string? input)
|
||||
{
|
||||
var model = AlarmTriggerConfigCodec.Parse(input, AlarmTriggerType.ValueMatch);
|
||||
|
||||
Assert.Null(model.AttributeName);
|
||||
Assert.Null(model.MatchValue);
|
||||
Assert.False(model.NotEquals);
|
||||
Assert.Equal("either", model.Direction);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_MalformedJson_ReturnsDefaultModel_DoesNotThrow()
|
||||
{
|
||||
var model = AlarmTriggerConfigCodec.Parse("{not valid", AlarmTriggerType.RangeViolation);
|
||||
|
||||
Assert.Null(model.Min);
|
||||
Assert.Null(model.Max);
|
||||
}
|
||||
|
||||
// ── Serialize: ValueMatch ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Serialize_ValueMatch_WritesCanonicalKeysOnly()
|
||||
{
|
||||
var model = new AlarmTriggerModel
|
||||
{
|
||||
AttributeName = "Status",
|
||||
MatchValue = "Critical",
|
||||
// Foreign fields from other trigger types must NOT leak into the JSON.
|
||||
Min = 5,
|
||||
ThresholdPerSecond = 99,
|
||||
Direction = "rising"
|
||||
};
|
||||
|
||||
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.ValueMatch);
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
Assert.Equal("Status", root.GetProperty("attributeName").GetString());
|
||||
Assert.Equal("Critical", root.GetProperty("matchValue").GetString());
|
||||
Assert.False(root.TryGetProperty("min", out _));
|
||||
Assert.False(root.TryGetProperty("thresholdPerSecond", out _));
|
||||
Assert.False(root.TryGetProperty("direction", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_ValueMatch_NotEquals_PrependsBangEqualsToMatchValue()
|
||||
{
|
||||
var model = new AlarmTriggerModel
|
||||
{
|
||||
AttributeName = "Status",
|
||||
MatchValue = "Good",
|
||||
NotEquals = true
|
||||
};
|
||||
|
||||
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.ValueMatch);
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
Assert.Equal("!=Good", doc.RootElement.GetProperty("matchValue").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_ValueMatch_NullAttributeName_WritesEmptyString()
|
||||
{
|
||||
// AlarmActor uses attributeName for subscription filtering, so the key
|
||||
// must always be present even when the user hasn't picked one yet.
|
||||
var model = new AlarmTriggerModel { MatchValue = "x" };
|
||||
|
||||
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.ValueMatch);
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
Assert.Equal("", doc.RootElement.GetProperty("attributeName").GetString());
|
||||
}
|
||||
|
||||
// ── Serialize: RangeViolation ──────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Serialize_RangeViolation_WritesCanonicalNumericKeys()
|
||||
{
|
||||
var model = new AlarmTriggerModel
|
||||
{
|
||||
AttributeName = "Temp",
|
||||
Min = 0,
|
||||
Max = 100,
|
||||
MatchValue = "ignored"
|
||||
};
|
||||
|
||||
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.RangeViolation);
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
Assert.Equal(0, root.GetProperty("min").GetDouble());
|
||||
Assert.Equal(100, root.GetProperty("max").GetDouble());
|
||||
Assert.False(root.TryGetProperty("matchValue", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_RangeViolation_NullBound_OmitsKey()
|
||||
{
|
||||
var model = new AlarmTriggerModel { AttributeName = "Temp", Min = 0, Max = null };
|
||||
|
||||
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.RangeViolation);
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
Assert.True(doc.RootElement.TryGetProperty("min", out _));
|
||||
Assert.False(doc.RootElement.TryGetProperty("max", out _));
|
||||
}
|
||||
|
||||
// ── Serialize: RateOfChange ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Serialize_RateOfChange_WritesThresholdWindowAndDirection()
|
||||
{
|
||||
var model = new AlarmTriggerModel
|
||||
{
|
||||
AttributeName = "Pressure",
|
||||
ThresholdPerSecond = 25,
|
||||
WindowSeconds = 2,
|
||||
Direction = "falling"
|
||||
};
|
||||
|
||||
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.RateOfChange);
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
Assert.Equal(25, root.GetProperty("thresholdPerSecond").GetDouble());
|
||||
Assert.Equal(2, root.GetProperty("windowSeconds").GetDouble());
|
||||
Assert.Equal("falling", root.GetProperty("direction").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_RateOfChange_AlwaysIncludesDirection()
|
||||
{
|
||||
// Even with a default-constructed model, the runtime needs to know how
|
||||
// to evaluate — direction defaults to "either" and is always emitted.
|
||||
var model = new AlarmTriggerModel { AttributeName = "x" };
|
||||
|
||||
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.RateOfChange);
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
Assert.Equal("either", doc.RootElement.GetProperty("direction").GetString());
|
||||
}
|
||||
|
||||
// ── Round-trip ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_ValueMatch_NotEquals_Preserved()
|
||||
{
|
||||
var original = new AlarmTriggerModel
|
||||
{
|
||||
AttributeName = "Status",
|
||||
MatchValue = "Good",
|
||||
NotEquals = true
|
||||
};
|
||||
|
||||
var json = AlarmTriggerConfigCodec.Serialize(original, AlarmTriggerType.ValueMatch);
|
||||
var round = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.ValueMatch);
|
||||
|
||||
Assert.Equal(original.AttributeName, round.AttributeName);
|
||||
Assert.Equal(original.MatchValue, round.MatchValue);
|
||||
Assert.True(round.NotEquals);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_RangeViolation_Preserved()
|
||||
{
|
||||
var original = new AlarmTriggerModel
|
||||
{
|
||||
AttributeName = "Temp",
|
||||
Min = -10.5,
|
||||
Max = 42.25
|
||||
};
|
||||
|
||||
var json = AlarmTriggerConfigCodec.Serialize(original, AlarmTriggerType.RangeViolation);
|
||||
var round = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.RangeViolation);
|
||||
|
||||
Assert.Equal(original.Min, round.Min);
|
||||
Assert.Equal(original.Max, round.Max);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_RateOfChange_Preserved()
|
||||
{
|
||||
var original = new AlarmTriggerModel
|
||||
{
|
||||
AttributeName = "Pressure",
|
||||
ThresholdPerSecond = 25,
|
||||
WindowSeconds = 2,
|
||||
Direction = "rising"
|
||||
};
|
||||
|
||||
var json = AlarmTriggerConfigCodec.Serialize(original, AlarmTriggerType.RateOfChange);
|
||||
var round = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.RateOfChange);
|
||||
|
||||
Assert.Equal(original.AttributeName, round.AttributeName);
|
||||
Assert.Equal(original.ThresholdPerSecond, round.ThresholdPerSecond);
|
||||
Assert.Equal(original.WindowSeconds, round.WindowSeconds);
|
||||
Assert.Equal(original.Direction, round.Direction);
|
||||
}
|
||||
|
||||
// ── Parse: HiLo ────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Parse_HiLo_ReadsAllSetpointsAndPriorities()
|
||||
{
|
||||
const string json = @"{""attributeName"":""Temp"",""loLo"":0,""lo"":10,""hi"":90,""hiHi"":100,""loLoPriority"":900,""loPriority"":500,""hiPriority"":500,""hiHiPriority"":900}";
|
||||
|
||||
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.HiLo);
|
||||
|
||||
Assert.Equal("Temp", model.AttributeName);
|
||||
Assert.Equal(0, model.LoLo);
|
||||
Assert.Equal(10, model.Lo);
|
||||
Assert.Equal(90, model.Hi);
|
||||
Assert.Equal(100, model.HiHi);
|
||||
Assert.Equal(900, model.LoLoPriority);
|
||||
Assert.Equal(500, model.LoPriority);
|
||||
Assert.Equal(500, model.HiPriority);
|
||||
Assert.Equal(900, model.HiHiPriority);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_HiLo_AcceptsPartialSetpoints_MissingOnesAreNull()
|
||||
{
|
||||
// Common case: only Hi/HiHi configured for over-temp protection.
|
||||
const string json = @"{""attributeName"":""Temp"",""hi"":80,""hiHi"":100}";
|
||||
|
||||
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.HiLo);
|
||||
|
||||
Assert.Null(model.LoLo);
|
||||
Assert.Null(model.Lo);
|
||||
Assert.Equal(80, model.Hi);
|
||||
Assert.Equal(100, model.HiHi);
|
||||
Assert.Null(model.HiPriority);
|
||||
}
|
||||
|
||||
// ── Serialize: HiLo ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Serialize_HiLo_OmitsNullSetpointsAndPriorities()
|
||||
{
|
||||
var model = new AlarmTriggerModel
|
||||
{
|
||||
AttributeName = "Temp",
|
||||
Hi = 80,
|
||||
HiHi = 100,
|
||||
HiHiPriority = 900
|
||||
// Lo, LoLo, and the other priorities left null
|
||||
};
|
||||
|
||||
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.HiLo);
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
Assert.Equal(80, root.GetProperty("hi").GetDouble());
|
||||
Assert.Equal(100, root.GetProperty("hiHi").GetDouble());
|
||||
Assert.Equal(900, root.GetProperty("hiHiPriority").GetInt32());
|
||||
Assert.False(root.TryGetProperty("lo", out _));
|
||||
Assert.False(root.TryGetProperty("loLo", out _));
|
||||
Assert.False(root.TryGetProperty("hiPriority", out _));
|
||||
Assert.False(root.TryGetProperty("loPriority", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_HiLo_DoesNotLeakForeignTriggerTypeFields()
|
||||
{
|
||||
// matchValue, min/max, threshold/window/direction must NOT show up in
|
||||
// HiLo output even if the model happens to carry them.
|
||||
var model = new AlarmTriggerModel
|
||||
{
|
||||
AttributeName = "Temp",
|
||||
Hi = 80,
|
||||
MatchValue = "ignored",
|
||||
Min = 1,
|
||||
ThresholdPerSecond = 99,
|
||||
Direction = "rising"
|
||||
};
|
||||
|
||||
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.HiLo);
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
Assert.False(root.TryGetProperty("matchValue", out _));
|
||||
Assert.False(root.TryGetProperty("min", out _));
|
||||
Assert.False(root.TryGetProperty("thresholdPerSecond", out _));
|
||||
Assert.False(root.TryGetProperty("direction", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_HiLo_ReadsDeadbands()
|
||||
{
|
||||
const string json = @"{""attributeName"":""Temp"",""hi"":80,""hiHi"":100,""hiDeadband"":2,""hiHiDeadband"":5}";
|
||||
|
||||
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.HiLo);
|
||||
|
||||
Assert.Equal(2, model.HiDeadband);
|
||||
Assert.Equal(5, model.HiHiDeadband);
|
||||
Assert.Null(model.LoDeadband);
|
||||
Assert.Null(model.LoLoDeadband);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_HiLo_OmitsNullDeadbands()
|
||||
{
|
||||
var model = new AlarmTriggerModel
|
||||
{
|
||||
AttributeName = "Temp",
|
||||
Hi = 80,
|
||||
HiDeadband = 2
|
||||
// HiHiDeadband / LoDeadband / LoLoDeadband null
|
||||
};
|
||||
|
||||
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.HiLo);
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
Assert.Equal(2, doc.RootElement.GetProperty("hiDeadband").GetDouble());
|
||||
Assert.False(doc.RootElement.TryGetProperty("hiHiDeadband", out _));
|
||||
Assert.False(doc.RootElement.TryGetProperty("loDeadband", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_HiLo_PreservesAllFields()
|
||||
{
|
||||
var original = new AlarmTriggerModel
|
||||
{
|
||||
AttributeName = "Pressure",
|
||||
LoLo = -5,
|
||||
Lo = 0,
|
||||
Hi = 90,
|
||||
HiHi = 110,
|
||||
LoLoPriority = 800,
|
||||
LoPriority = 400,
|
||||
HiPriority = 400,
|
||||
HiHiPriority = 800,
|
||||
LoLoDeadband = 1,
|
||||
LoDeadband = 2,
|
||||
HiDeadband = 3,
|
||||
HiHiDeadband = 4
|
||||
};
|
||||
|
||||
var json = AlarmTriggerConfigCodec.Serialize(original, AlarmTriggerType.HiLo);
|
||||
var round = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.HiLo);
|
||||
|
||||
Assert.Equal(original.AttributeName, round.AttributeName);
|
||||
Assert.Equal(original.LoLo, round.LoLo);
|
||||
Assert.Equal(original.Lo, round.Lo);
|
||||
Assert.Equal(original.Hi, round.Hi);
|
||||
Assert.Equal(original.HiHi, round.HiHi);
|
||||
Assert.Equal(original.LoLoPriority, round.LoLoPriority);
|
||||
Assert.Equal(original.LoPriority, round.LoPriority);
|
||||
Assert.Equal(original.HiPriority, round.HiPriority);
|
||||
Assert.Equal(original.HiHiPriority, round.HiHiPriority);
|
||||
Assert.Equal(original.LoLoDeadband, round.LoLoDeadband);
|
||||
Assert.Equal(original.LoDeadband, round.LoDeadband);
|
||||
Assert.Equal(original.HiDeadband, round.HiDeadband);
|
||||
Assert.Equal(original.HiHiDeadband, round.HiHiDeadband);
|
||||
}
|
||||
|
||||
// ── NormalizeDirection (direct) ────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("rising", "rising")]
|
||||
[InlineData("RISING", "rising")]
|
||||
[InlineData("falling", "falling")]
|
||||
[InlineData("up", "rising")]
|
||||
[InlineData("down", "falling")]
|
||||
[InlineData("positive", "rising")]
|
||||
[InlineData("negative", "falling")]
|
||||
[InlineData("either", "either")]
|
||||
[InlineData("", "either")]
|
||||
[InlineData(null, "either")]
|
||||
[InlineData("nonsense", "either")]
|
||||
public void NormalizeDirection_HandlesAllAliasesAndFallsBackToEither(string? input, string expected)
|
||||
{
|
||||
Assert.Equal(expected, AlarmTriggerConfigCodec.NormalizeDirection(input));
|
||||
}
|
||||
}
|
||||
@@ -71,22 +71,31 @@ public class ArchitecturalConstraintTests
|
||||
[Fact]
|
||||
public void Commons_ShouldNotContainServiceOrActorImplementations()
|
||||
{
|
||||
// Heuristic: class has > 3 public non-property methods that are not constructors
|
||||
// Heuristic: class has > 3 public methods that are neither constructors,
|
||||
// property accessors, common Object overrides, nor interface-implementation
|
||||
// methods (a dictionary wrapper exposing ContainsKey/TryGetValue/GetEnumerator
|
||||
// via IReadOnlyDictionary isn't a service — that's just the interface).
|
||||
var types = CommonsAssembly.GetTypes()
|
||||
.Where(t => t.IsClass && !t.IsAbstract && !t.IsInterface);
|
||||
|
||||
foreach (var type in types)
|
||||
{
|
||||
var interfaceMethodNames = type.GetInterfaces()
|
||||
.SelectMany(i => i.GetMethods())
|
||||
.Select(m => m.Name)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var publicMethods = type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
|
||||
.Where(m => !m.IsSpecialName) // excludes property getters/setters
|
||||
.Where(m => !m.Name.StartsWith("<")) // excludes compiler-generated
|
||||
.Where(m => m.Name != "ToString" && m.Name != "GetHashCode" &&
|
||||
m.Name != "Equals" && m.Name != "Deconstruct" &&
|
||||
m.Name != "PrintMembers" && m.Name != "GetType")
|
||||
.Where(m => !interfaceMethodNames.Contains(m.Name))
|
||||
.ToList();
|
||||
|
||||
Assert.True(publicMethods.Count <= 3,
|
||||
$"Type {type.FullName} has {publicMethods.Count} public methods ({string.Join(", ", publicMethods.Select(m => m.Name))}), which suggests it may contain service/actor logic");
|
||||
$"Type {type.FullName} has {publicMethods.Count} public non-interface methods ({string.Join(", ", publicMethods.Select(m => m.Name))}), which suggests it may contain service/actor logic");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ public class EnumTests
|
||||
[InlineData(typeof(InstanceState), new[] { "NotDeployed", "Enabled", "Disabled" })]
|
||||
[InlineData(typeof(DeploymentStatus), new[] { "Pending", "InProgress", "Success", "Failed" })]
|
||||
[InlineData(typeof(AlarmState), new[] { "Active", "Normal" })]
|
||||
[InlineData(typeof(AlarmTriggerType), new[] { "ValueMatch", "RangeViolation", "RateOfChange" })]
|
||||
[InlineData(typeof(AlarmLevel), new[] { "None", "Low", "LowLow", "High", "HighHigh" })]
|
||||
[InlineData(typeof(AlarmTriggerType), new[] { "ValueMatch", "RangeViolation", "RateOfChange", "HiLo" })]
|
||||
[InlineData(typeof(ConnectionHealth), new[] { "Connected", "Disconnected", "Connecting", "Error" })]
|
||||
public void Enum_ShouldHaveExpectedValues(Type enumType, string[] expectedNames)
|
||||
{
|
||||
@@ -22,6 +23,7 @@ public class EnumTests
|
||||
[InlineData(typeof(InstanceState))]
|
||||
[InlineData(typeof(DeploymentStatus))]
|
||||
[InlineData(typeof(AlarmState))]
|
||||
[InlineData(typeof(AlarmLevel))]
|
||||
[InlineData(typeof(AlarmTriggerType))]
|
||||
[InlineData(typeof(ConnectionHealth))]
|
||||
public void Enum_ShouldBeSingularNamed(Type enumType)
|
||||
|
||||
@@ -98,6 +98,44 @@ public class SiteStreamGrpcClientTests
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AlarmLevelEnum.AlarmLevelNone, AlarmLevel.None)]
|
||||
[InlineData(AlarmLevelEnum.AlarmLevelLow, AlarmLevel.Low)]
|
||||
[InlineData(AlarmLevelEnum.AlarmLevelLowLow, AlarmLevel.LowLow)]
|
||||
[InlineData(AlarmLevelEnum.AlarmLevelHigh, AlarmLevel.High)]
|
||||
[InlineData(AlarmLevelEnum.AlarmLevelHighHigh, AlarmLevel.HighHigh)]
|
||||
public void MapAlarmLevel_AllValues(AlarmLevelEnum input, AlarmLevel expected)
|
||||
{
|
||||
var result = SiteStreamGrpcClient.MapAlarmLevel(input);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToDomainEvent_AlarmChanged_PreservesLevel()
|
||||
{
|
||||
// Round-trip: a HiLo alarm emitted at HighHigh must come through with Level intact.
|
||||
var evt = new SiteStreamEvent
|
||||
{
|
||||
CorrelationId = "test",
|
||||
AlarmChanged = new AlarmStateUpdate
|
||||
{
|
||||
InstanceUniqueName = "Pump1",
|
||||
AlarmName = "TempAlarm",
|
||||
State = AlarmStateEnum.AlarmStateActive,
|
||||
Priority = 900,
|
||||
Timestamp = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
|
||||
Level = AlarmLevelEnum.AlarmLevelHighHigh
|
||||
}
|
||||
};
|
||||
|
||||
var domain = SiteStreamGrpcClient.ConvertToDomainEvent(evt) as AlarmStateChanged;
|
||||
|
||||
Assert.NotNull(domain);
|
||||
Assert.Equal(AlarmState.Active, domain.State);
|
||||
Assert.Equal(AlarmLevel.HighHigh, domain.Level);
|
||||
Assert.Equal(900, domain.Priority);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unsubscribe_CancelsSubscription()
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ScadaLink.Commons.Messages.Streaming;
|
||||
@@ -257,4 +258,618 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
// No additional messages (no script execution side effects)
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
// ── RateOfChange ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Builds a RateOfChange config JSON with the given threshold (units/sec),
|
||||
/// window (seconds), and direction. Used by the rate-of-change tests.
|
||||
/// </summary>
|
||||
private static string RocConfig(double thresholdPerSecond, double windowSeconds, string direction) =>
|
||||
$"{{\"attributeName\":\"Pressure\",\"thresholdPerSecond\":{thresholdPerSecond.ToString(System.Globalization.CultureInfo.InvariantCulture)},\"windowSeconds\":{windowSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture)},\"direction\":\"{direction}\"}}";
|
||||
|
||||
private IActorRef SpawnRocAlarm(string config, TestProbe instanceProbe)
|
||||
{
|
||||
var alarmConfig = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "RocAlarm",
|
||||
TriggerType = "RateOfChange",
|
||||
TriggerConfiguration = config,
|
||||
PriorityLevel = 3
|
||||
};
|
||||
return ActorOf(Props.Create(() => new AlarmActor(
|
||||
"RocAlarm", "Pump1", instanceProbe.Ref, alarmConfig,
|
||||
null, _sharedLibrary, _options,
|
||||
NullLogger<AlarmActor>.Instance)));
|
||||
}
|
||||
|
||||
private static AttributeValueChanged PressureSample(double value, DateTimeOffset ts) =>
|
||||
new("Pump1", "Pressure", "Pressure",
|
||||
value.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
"Good", ts);
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_Either_ActivatesOnRapidRise()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
// 50 units/sec threshold, 2 sec window
|
||||
var alarm = SpawnRocAlarm(RocConfig(50, 2, "either"), instanceProbe);
|
||||
|
||||
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
// First sample establishes the window baseline; needs ≥2 samples to compute a rate.
|
||||
alarm.Tell(PressureSample(0, t0));
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
// 100 over 1 sec = 100 units/sec > 50 threshold → activate
|
||||
alarm.Tell(PressureSample(100, t0.AddSeconds(1)));
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, msg.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_Either_ActivatesOnRapidFall()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = SpawnRocAlarm(RocConfig(50, 2, "either"), instanceProbe);
|
||||
|
||||
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
alarm.Tell(PressureSample(100, t0));
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
// -100 over 1 sec → |rate| = 100 > 50 → activate (Either covers both signs)
|
||||
alarm.Tell(PressureSample(0, t0.AddSeconds(1)));
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, msg.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_Either_DoesNotActivateWhenBelowThreshold()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = SpawnRocAlarm(RocConfig(50, 2, "either"), instanceProbe);
|
||||
|
||||
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
alarm.Tell(PressureSample(0, t0));
|
||||
// 10 over 1 sec = 10 units/sec < 50 threshold → no alarm
|
||||
alarm.Tell(PressureSample(10, t0.AddSeconds(1)));
|
||||
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_Rising_IgnoresFallingSpikes()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = SpawnRocAlarm(RocConfig(50, 2, "rising"), instanceProbe);
|
||||
|
||||
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
alarm.Tell(PressureSample(100, t0));
|
||||
// -100 over 1 sec → would trigger Either, but Rising only fires on positive rate
|
||||
alarm.Tell(PressureSample(0, t0.AddSeconds(1)));
|
||||
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_Falling_IgnoresRisingSpikes()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = SpawnRocAlarm(RocConfig(50, 2, "falling"), instanceProbe);
|
||||
|
||||
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
alarm.Tell(PressureSample(0, t0));
|
||||
alarm.Tell(PressureSample(100, t0.AddSeconds(1)));
|
||||
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_Falling_ActivatesOnFallingRate()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = SpawnRocAlarm(RocConfig(50, 2, "falling"), instanceProbe);
|
||||
|
||||
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
alarm.Tell(PressureSample(100, t0));
|
||||
alarm.Tell(PressureSample(0, t0.AddSeconds(1))); // -100/sec, |rate| > threshold, falling
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, msg.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_SingleSample_DoesNotActivate()
|
||||
{
|
||||
// The evaluator needs at least two samples in the window to compute a rate.
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = SpawnRocAlarm(RocConfig(50, 2, "either"), instanceProbe);
|
||||
|
||||
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
alarm.Tell(PressureSample(1000, t0));
|
||||
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_WindowRollsOff_OldSamplesDiscarded()
|
||||
{
|
||||
// 1-second window. Sample at t=0 with value 0 should fall out before the
|
||||
// sample at t=3, so the in-window history is just the two recent samples
|
||||
// (t=2.5, v=99) and (t=3, v=100) → rate = 1 unit / 0.5s = 2/sec — below
|
||||
// the threshold, so no alarm even though the long-term delta is huge.
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = SpawnRocAlarm(RocConfig(50, 1, "either"), instanceProbe);
|
||||
|
||||
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
alarm.Tell(PressureSample(0, t0));
|
||||
alarm.Tell(PressureSample(99, t0.AddSeconds(2.5)));
|
||||
alarm.Tell(PressureSample(100, t0.AddSeconds(3)));
|
||||
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_ClearsWhenRateDropsBack()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = SpawnRocAlarm(RocConfig(50, 1, "either"), instanceProbe);
|
||||
|
||||
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
// Spike: activate
|
||||
alarm.Tell(PressureSample(0, t0));
|
||||
alarm.Tell(PressureSample(100, t0.AddSeconds(0.5))); // 200/sec > 50
|
||||
var activate = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, activate.State);
|
||||
|
||||
// Now sample again well past the 1-second window with only a tiny change
|
||||
// → rate falls below threshold → clears.
|
||||
alarm.Tell(PressureSample(101, t0.AddSeconds(3)));
|
||||
|
||||
var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Normal, clear.State);
|
||||
}
|
||||
|
||||
// ── Legacy JSON aliases & not-equals prefix ────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_ValueMatch_LegacyAttributeAndValueKeys_StillFire()
|
||||
{
|
||||
// Old configs used "attribute" / "value" before the canonical names landed.
|
||||
var alarmConfig = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "Legacy",
|
||||
TriggerType = "ValueMatch",
|
||||
TriggerConfiguration = "{\"attribute\":\"Status\",\"value\":\"Critical\"}",
|
||||
PriorityLevel = 1
|
||||
};
|
||||
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
||||
"Legacy", "Pump1", instanceProbe.Ref, alarmConfig,
|
||||
null, _sharedLibrary, _options,
|
||||
NullLogger<AlarmActor>.Instance)));
|
||||
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow));
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, msg.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_ValueMatch_NotEqualsPrefix_FiresWhenValueDiffers()
|
||||
{
|
||||
// matchValue "!=Good" means: alarm when Status is anything other than Good.
|
||||
var alarmConfig = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "Inverted",
|
||||
TriggerType = "ValueMatch",
|
||||
TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"!=Good\"}",
|
||||
PriorityLevel = 1
|
||||
};
|
||||
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
||||
"Inverted", "Pump1", instanceProbe.Ref, alarmConfig,
|
||||
null, _sharedLibrary, _options,
|
||||
NullLogger<AlarmActor>.Instance)));
|
||||
|
||||
// Status=Critical (not "Good") → alarm activates
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow));
|
||||
var activate = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, activate.State);
|
||||
|
||||
// Status=Good → alarm clears
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Status", "Status", "Good", "Critical", DateTimeOffset.UtcNow));
|
||||
var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Normal, clear.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RangeViolation_LegacyLowHighKeys_StillFire()
|
||||
{
|
||||
// Older configs used "low" / "high" instead of the current "min" / "max".
|
||||
var alarmConfig = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "Legacy",
|
||||
TriggerType = "RangeViolation",
|
||||
TriggerConfiguration = "{\"attributeName\":\"Temperature\",\"low\":0,\"high\":100}",
|
||||
PriorityLevel = 1
|
||||
};
|
||||
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
||||
"Legacy", "Pump1", instanceProbe.Ref, alarmConfig,
|
||||
null, _sharedLibrary, _options,
|
||||
NullLogger<AlarmActor>.Instance)));
|
||||
|
||||
// Within range → no alarm
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Temperature", "Temperature", "50", "Good", DateTimeOffset.UtcNow));
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
// Outside range → activate
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Temperature", "Temperature", "150", "Good", DateTimeOffset.UtcNow));
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, msg.State);
|
||||
}
|
||||
|
||||
// ── HiLo ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Spawns a HiLo alarm with the given JSON config and alarm-level priority fallback.</summary>
|
||||
private IActorRef SpawnHiLoAlarm(string config, TestProbe instanceProbe, int priority = 500)
|
||||
{
|
||||
var alarmConfig = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "TempAlarm",
|
||||
TriggerType = "HiLo",
|
||||
TriggerConfiguration = config,
|
||||
PriorityLevel = priority
|
||||
};
|
||||
return ActorOf(Props.Create(() => new AlarmActor(
|
||||
"TempAlarm", "Pump1", instanceProbe.Ref, alarmConfig,
|
||||
null, _sharedLibrary, _options,
|
||||
NullLogger<AlarmActor>.Instance)));
|
||||
}
|
||||
|
||||
private static AttributeValueChanged TempSample(double value) =>
|
||||
new("Pump1", "Temperature", "Temperature",
|
||||
value.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
"Good", DateTimeOffset.UtcNow);
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_EntersHigh_WhenValueCrossesHi()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(50)); // normal band — no emit
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
alarm.Tell(TempSample(85)); // crosses Hi but not HiHi
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, msg.State);
|
||||
Assert.Equal(AlarmLevel.High, msg.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_EscalatesToHighHigh_WhenValueClimbsPastHiHi()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(85));
|
||||
var first = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.High, first.Level);
|
||||
|
||||
alarm.Tell(TempSample(120)); // crosses HiHi
|
||||
|
||||
var second = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, second.State);
|
||||
Assert.Equal(AlarmLevel.HighHigh, second.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_DescalatesFromHighHighToHigh_WhenValueDrops()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(120));
|
||||
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
|
||||
alarm.Tell(TempSample(85)); // back into the Hi band but still alarmed
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, msg.State);
|
||||
Assert.Equal(AlarmLevel.High, msg.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_ClearsToNormal_WhenValueReturnsToNormalBand()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(85));
|
||||
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
|
||||
alarm.Tell(TempSample(50)); // back to normal
|
||||
|
||||
var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Normal, clear.State);
|
||||
Assert.Equal(AlarmLevel.None, clear.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_EntersLow_WhenValueCrossesLo()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""loLo"":0,""lo"":10}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(8));
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.Low, msg.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_EntersLowLow_WhenValueCrossesLoLo()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""loLo"":0,""lo"":10}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(-5));
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.LowLow, msg.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_PerSetpointPriority_OverridesAlarmLevelPriority()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
// Alarm-level priority is 500; HiHi explicitly bumps to 900.
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiPriority"":600,""hiHiPriority"":900}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe, priority: 500);
|
||||
|
||||
alarm.Tell(TempSample(85));
|
||||
var hi = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.High, hi.Level);
|
||||
Assert.Equal(600, hi.Priority);
|
||||
|
||||
alarm.Tell(TempSample(120));
|
||||
var hiHi = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.HighHigh, hiHi.Level);
|
||||
Assert.Equal(900, hiHi.Priority);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_MissingPerSetpointPriority_FallsBackToAlarmLevel()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe, priority: 432);
|
||||
|
||||
alarm.Tell(TempSample(85));
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(432, msg.Priority);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_PartialConfig_OnlyHiHiSet_NoEffectInLowRange()
|
||||
{
|
||||
// Only HiHi is configured — values that would have hit a Lo or Hi band
|
||||
// (in a fully-configured alarm) are inside the implicit normal band here.
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hiHi"":100}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(-1000));
|
||||
alarm.Tell(TempSample(95));
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
alarm.Tell(TempSample(110));
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.HighHigh, msg.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_BoundaryValue_AtHiHi_ResolvesToHighHigh()
|
||||
{
|
||||
// When the value exactly equals the boundary, the most-severe matching
|
||||
// band wins. value == HiHi → HighHigh (not High).
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(100));
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.HighHigh, msg.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_StaysAtSameLevel_NoRedundantEmission()
|
||||
{
|
||||
// Two updates that resolve to the same level should produce exactly one
|
||||
// AlarmStateChanged — the second is a no-op.
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(85));
|
||||
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
|
||||
alarm.Tell(TempSample(90)); // still in the Hi band
|
||||
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(400));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_NoSetpointsConfigured_NeverFires()
|
||||
{
|
||||
// Validation flags this as a warning at design time; runtime behavior
|
||||
// is "evaluates to None forever".
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature""}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(99999));
|
||||
alarm.Tell(TempSample(-99999));
|
||||
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(400));
|
||||
}
|
||||
|
||||
// ── HiLo hysteresis ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_Hysteresis_StaysAtHighHigh_UntilDropsBelowDeadband()
|
||||
{
|
||||
// HiHi=100 with 5-unit deadband. Once at HighHigh, the alarm stays there
|
||||
// until the value drops below 95 — at 96 it should still be HighHigh.
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiHiDeadband"":5}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(120));
|
||||
var entered = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.HighHigh, entered.Level);
|
||||
|
||||
// 96 > 95 (HiHi - deadband) → still HighHigh, no state change emitted
|
||||
alarm.Tell(TempSample(96));
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_Hysteresis_DropsToHigh_OnlyAfterDeadbandCleared()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiHiDeadband"":5}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(120));
|
||||
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
|
||||
// 94 < 95 (HiHi - deadband) → drops to High (still above Hi=80)
|
||||
alarm.Tell(TempSample(94));
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.High, msg.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_Hysteresis_HiDeadband_PreventsFlapping()
|
||||
{
|
||||
// Hi=80 with 5-unit deadband. After entering Hi, stays Hi until value drops below 75.
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiDeadband"":5}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(85));
|
||||
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
|
||||
alarm.Tell(TempSample(78)); // 78 > 75 → still High
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
alarm.Tell(TempSample(74)); // 74 < 75 → clears to Normal
|
||||
var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.None, clear.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_Hysteresis_LowSide_Symmetric()
|
||||
{
|
||||
// Lo=10 with 3-unit deadband. After entering Lo, stays Lo until value rises above 13.
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""loLo"":0,""lo"":10,""loDeadband"":3}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(8));
|
||||
var entered = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.Low, entered.Level);
|
||||
|
||||
alarm.Tell(TempSample(12)); // 12 <= 13 → still Low
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
alarm.Tell(TempSample(14)); // 14 > 13 → clears
|
||||
var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.None, clear.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_PerBandMessage_FlowsToAlarmStateChanged()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiMessage"":""Coolant warm — check tank"",""hiHiMessage"":""Coolant critical — shut down""}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(85));
|
||||
var hi = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("Coolant warm — check tank", hi.Message);
|
||||
|
||||
alarm.Tell(TempSample(120));
|
||||
var hiHi = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("Coolant critical — shut down", hiHi.Message);
|
||||
|
||||
// Clearing back to normal carries an empty message.
|
||||
alarm.Tell(TempSample(50));
|
||||
var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(string.Empty, clear.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_Hysteresis_DoesNotDelayEscalation()
|
||||
{
|
||||
// Deadband is only on de-escalation. Escalating up to HighHigh should not be delayed.
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiHiDeadband"":50}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(85));
|
||||
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
|
||||
// Despite the large deadband, escalation uses the activation threshold (100).
|
||||
alarm.Tell(TempSample(101));
|
||||
var escalated = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.HighHigh, escalated.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_MalformedTriggerConfig_DoesNotCrash()
|
||||
{
|
||||
// ParseEvalConfig falls back to a safe default on JSON failure; the actor
|
||||
// should accept messages without throwing and just never trigger.
|
||||
var alarmConfig = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "Bad",
|
||||
TriggerType = "ValueMatch",
|
||||
TriggerConfiguration = "{not valid json",
|
||||
PriorityLevel = 1
|
||||
};
|
||||
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
||||
"Bad", "Pump1", instanceProbe.Ref, alarmConfig,
|
||||
null, _sharedLibrary, _options,
|
||||
NullLogger<AlarmActor>.Instance)));
|
||||
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Anything", "Anything", "anything", "Good", DateTimeOffset.UtcNow));
|
||||
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
using System.Text.Json;
|
||||
using ScadaLink.Commons.Entities.Instances;
|
||||
using ScadaLink.Commons.Entities.Sites;
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.Commons.Types.Flattening;
|
||||
using ScadaLink.TemplateEngine.Flattening;
|
||||
|
||||
namespace ScadaLink.TemplateEngine.Tests.Flattening;
|
||||
|
||||
public class FlatteningServiceMergeTests
|
||||
{
|
||||
// ── MergeHiLoConfig ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void MergeHiLoConfig_DerivedKeysWin_InheritedKeysSurvive()
|
||||
{
|
||||
const string inherited = @"{""attributeName"":""Temp"",""loLo"":0,""lo"":10,""hi"":80,""hiHi"":100}";
|
||||
const string derived = @"{""hi"":90}"; // derived only overrides Hi
|
||||
|
||||
var result = FlatteningService.MergeHiLoConfig(inherited, derived);
|
||||
|
||||
Assert.NotNull(result);
|
||||
using var doc = JsonDocument.Parse(result!);
|
||||
Assert.Equal("Temp", doc.RootElement.GetProperty("attributeName").GetString());
|
||||
Assert.Equal(0, doc.RootElement.GetProperty("loLo").GetDouble());
|
||||
Assert.Equal(10, doc.RootElement.GetProperty("lo").GetDouble());
|
||||
Assert.Equal(90, doc.RootElement.GetProperty("hi").GetDouble()); // overridden
|
||||
Assert.Equal(100, doc.RootElement.GetProperty("hiHi").GetDouble()); // inherited
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeHiLoConfig_DerivedCanOverrideAttribute()
|
||||
{
|
||||
const string inherited = @"{""attributeName"":""Temp"",""hi"":80}";
|
||||
const string derived = @"{""attributeName"":""Pressure"",""hi"":50}";
|
||||
|
||||
var result = FlatteningService.MergeHiLoConfig(inherited, derived);
|
||||
|
||||
using var doc = JsonDocument.Parse(result!);
|
||||
Assert.Equal("Pressure", doc.RootElement.GetProperty("attributeName").GetString());
|
||||
Assert.Equal(50, doc.RootElement.GetProperty("hi").GetDouble());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeHiLoConfig_DerivedNull_ReturnsInherited()
|
||||
{
|
||||
const string inherited = @"{""hi"":80}";
|
||||
|
||||
var result = FlatteningService.MergeHiLoConfig(inherited, null);
|
||||
|
||||
Assert.Equal(inherited, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeHiLoConfig_InheritedNull_ReturnsDerived()
|
||||
{
|
||||
const string derived = @"{""hi"":80}";
|
||||
|
||||
var result = FlatteningService.MergeHiLoConfig(null, derived);
|
||||
|
||||
Assert.Equal(derived, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeHiLoConfig_BothNull_ReturnsNull()
|
||||
{
|
||||
Assert.Null(FlatteningService.MergeHiLoConfig(null, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeHiLoConfig_MalformedInherited_FallsBackToDerived()
|
||||
{
|
||||
// Safe fallback — never throw on malformed input.
|
||||
const string derived = @"{""hi"":80}";
|
||||
|
||||
var result = FlatteningService.MergeHiLoConfig("{not valid", derived);
|
||||
|
||||
Assert.Equal(derived, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeHiLoConfig_DerivedAddsNewKey_PreservesInheritedRest()
|
||||
{
|
||||
// Derived adds a deadband that the base didn't have.
|
||||
const string inherited = @"{""hi"":80,""hiHi"":100}";
|
||||
const string derived = @"{""hiDeadband"":3}";
|
||||
|
||||
var result = FlatteningService.MergeHiLoConfig(inherited, derived);
|
||||
|
||||
using var doc = JsonDocument.Parse(result!);
|
||||
Assert.Equal(80, doc.RootElement.GetProperty("hi").GetDouble());
|
||||
Assert.Equal(100, doc.RootElement.GetProperty("hiHi").GetDouble());
|
||||
Assert.Equal(3, doc.RootElement.GetProperty("hiDeadband").GetDouble());
|
||||
}
|
||||
|
||||
// ── Instance-level alarm override (end-to-end Flatten) ─────────────────
|
||||
|
||||
private static (Template, Instance) BuildHiLoFixture(string inheritedJson, InstanceAlarmOverride? ovr = null, bool locked = false)
|
||||
{
|
||||
var template = new Template("PumpTpl")
|
||||
{
|
||||
Id = 1,
|
||||
Alarms = new List<TemplateAlarm>
|
||||
{
|
||||
new("Temp")
|
||||
{
|
||||
Id = 10,
|
||||
TemplateId = 1,
|
||||
TriggerType = AlarmTriggerType.HiLo,
|
||||
TriggerConfiguration = inheritedJson,
|
||||
PriorityLevel = 500,
|
||||
IsLocked = locked
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var instance = new Instance("Pump-1") { Id = 100, TemplateId = 1, SiteId = 1 };
|
||||
if (ovr != null) instance.AlarmOverrides.Add(ovr);
|
||||
return (template, instance);
|
||||
}
|
||||
|
||||
private static FlattenedConfiguration Flatten(Template template, Instance instance)
|
||||
{
|
||||
var sut = new FlatteningService();
|
||||
var result = sut.Flatten(
|
||||
instance,
|
||||
new[] { template },
|
||||
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||
new Dictionary<int, DataConnection>());
|
||||
if (!result.IsSuccess) Assert.Fail(result.Error);
|
||||
return result.Value!;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_InstanceAlarmOverride_HiLo_MergesSetpoints()
|
||||
{
|
||||
// Template has {hi=80, hiHi=100, lo=10, loLo=0}. Instance overrides hi=90 only.
|
||||
var (tpl, inst) = BuildHiLoFixture(
|
||||
@"{""attributeName"":""Temp"",""loLo"":0,""lo"":10,""hi"":80,""hiHi"":100}",
|
||||
new InstanceAlarmOverride("Temp")
|
||||
{
|
||||
InstanceId = 100,
|
||||
TriggerConfigurationOverride = @"{""hi"":90}"
|
||||
});
|
||||
|
||||
var flat = Flatten(tpl, inst);
|
||||
var alarm = flat.Alarms.Single();
|
||||
|
||||
using var doc = JsonDocument.Parse(alarm.TriggerConfiguration!);
|
||||
Assert.Equal(0, doc.RootElement.GetProperty("loLo").GetDouble());
|
||||
Assert.Equal(10, doc.RootElement.GetProperty("lo").GetDouble());
|
||||
Assert.Equal(90, doc.RootElement.GetProperty("hi").GetDouble()); // overridden
|
||||
Assert.Equal(100, doc.RootElement.GetProperty("hiHi").GetDouble()); // inherited
|
||||
Assert.Equal("Override", alarm.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_InstanceAlarmOverride_OverridesPriority()
|
||||
{
|
||||
var (tpl, inst) = BuildHiLoFixture(
|
||||
@"{""attributeName"":""Temp"",""hi"":80}",
|
||||
new InstanceAlarmOverride("Temp")
|
||||
{
|
||||
InstanceId = 100,
|
||||
PriorityLevelOverride = 950
|
||||
});
|
||||
|
||||
var flat = Flatten(tpl, inst);
|
||||
var alarm = flat.Alarms.Single();
|
||||
|
||||
Assert.Equal(950, alarm.PriorityLevel);
|
||||
Assert.Equal("Override", alarm.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_InstanceAlarmOverride_LockedAlarm_OverrideSilentlyIgnored()
|
||||
{
|
||||
// Locked alarm — override should be a no-op at flatten time. (The
|
||||
// InstanceService.SetAlarmOverrideAsync write-time check is what
|
||||
// prevents the override from being persisted in the first place;
|
||||
// this test covers the runtime safety net.)
|
||||
var (tpl, inst) = BuildHiLoFixture(
|
||||
@"{""attributeName"":""Temp"",""hi"":80}",
|
||||
new InstanceAlarmOverride("Temp")
|
||||
{
|
||||
InstanceId = 100,
|
||||
TriggerConfigurationOverride = @"{""hi"":999}"
|
||||
},
|
||||
locked: true);
|
||||
|
||||
var flat = Flatten(tpl, inst);
|
||||
var alarm = flat.Alarms.Single();
|
||||
|
||||
using var doc = JsonDocument.Parse(alarm.TriggerConfiguration!);
|
||||
Assert.Equal(80, doc.RootElement.GetProperty("hi").GetDouble()); // not overridden
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_InstanceAlarmOverride_UnknownName_DoesNothing()
|
||||
{
|
||||
// Override targets an alarm name that doesn't exist on the template —
|
||||
// silently ignored (same behavior as attribute overrides).
|
||||
var (tpl, inst) = BuildHiLoFixture(
|
||||
@"{""attributeName"":""Temp"",""hi"":80}",
|
||||
new InstanceAlarmOverride("DoesNotExist")
|
||||
{
|
||||
InstanceId = 100,
|
||||
TriggerConfigurationOverride = @"{""hi"":999}"
|
||||
});
|
||||
|
||||
var flat = Flatten(tpl, inst);
|
||||
var alarm = flat.Alarms.Single();
|
||||
|
||||
using var doc = JsonDocument.Parse(alarm.TriggerConfiguration!);
|
||||
Assert.Equal(80, doc.RootElement.GetProperty("hi").GetDouble());
|
||||
Assert.NotEqual("Override", alarm.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_InstanceAlarmOverride_BinaryTrigger_ReplacesWholeConfig()
|
||||
{
|
||||
// For non-HiLo trigger types, an instance override replaces the whole
|
||||
// TriggerConfiguration (no per-key merge).
|
||||
var template = new Template("PumpTpl")
|
||||
{
|
||||
Id = 1,
|
||||
Alarms = new List<TemplateAlarm>
|
||||
{
|
||||
new("Temp")
|
||||
{
|
||||
Id = 10,
|
||||
TemplateId = 1,
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
TriggerConfiguration = @"{""attributeName"":""T"",""min"":0,""max"":100}",
|
||||
PriorityLevel = 500
|
||||
}
|
||||
}
|
||||
};
|
||||
var instance = new Instance("Pump-1") { Id = 100, TemplateId = 1, SiteId = 1 };
|
||||
instance.AlarmOverrides.Add(new InstanceAlarmOverride("Temp")
|
||||
{
|
||||
InstanceId = 100,
|
||||
TriggerConfigurationOverride = @"{""attributeName"":""T"",""min"":-50,""max"":50}"
|
||||
});
|
||||
|
||||
var flat = Flatten(template, instance);
|
||||
var alarm = flat.Alarms.Single();
|
||||
|
||||
using var doc = JsonDocument.Parse(alarm.TriggerConfiguration!);
|
||||
Assert.Equal(-50, doc.RootElement.GetProperty("min").GetDouble());
|
||||
Assert.Equal(50, doc.RootElement.GetProperty("max").GetDouble());
|
||||
}
|
||||
}
|
||||
@@ -251,4 +251,158 @@ public class SemanticValidatorTests
|
||||
Assert.Empty(SemanticValidator.ParseParameterDefinitions(null));
|
||||
Assert.Empty(SemanticValidator.ParseParameterDefinitions(""));
|
||||
}
|
||||
|
||||
// ── HiLo validation ─────────────────────────────────────────────────────
|
||||
|
||||
private static FlattenedConfiguration HiLoConfig(string attrName, string dataType, string triggerJson) =>
|
||||
new()
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Attributes = [new ResolvedAttribute { CanonicalName = attrName, DataType = dataType }],
|
||||
Alarms =
|
||||
[
|
||||
new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "Hi/Lo Alarm",
|
||||
TriggerType = "HiLo",
|
||||
TriggerConfiguration = triggerJson
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Validate_HiLoOnNonNumericAttribute_ReturnsError()
|
||||
{
|
||||
var config = HiLoConfig("Status", "String",
|
||||
"{\"attributeName\":\"Status\",\"hi\":80}");
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
|
||||
Assert.Contains(result.Errors,
|
||||
e => e.Category == ValidationCategory.TriggerOperandType
|
||||
&& e.Message.Contains("HiLo")
|
||||
&& e.Message.Contains("non-numeric"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_HiLoOnNumericAttribute_NoOperandTypeError()
|
||||
{
|
||||
var config = HiLoConfig("Temp", "Double",
|
||||
"{\"attributeName\":\"Temp\",\"hi\":80,\"hiHi\":100}");
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
|
||||
Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.TriggerOperandType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_HiLoNoSetpoints_ReturnsWarning()
|
||||
{
|
||||
// No setpoints means the alarm can never fire — design-time warning.
|
||||
var config = HiLoConfig("Temp", "Double",
|
||||
"{\"attributeName\":\"Temp\"}");
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
|
||||
Assert.Contains(result.Warnings,
|
||||
w => w.Category == ValidationCategory.TriggerOperandType
|
||||
&& w.Message.Contains("no setpoints"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_HiLoLoLoGreaterThanLo_ReturnsError()
|
||||
{
|
||||
var config = HiLoConfig("Temp", "Double",
|
||||
"{\"attributeName\":\"Temp\",\"loLo\":20,\"lo\":10}");
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
|
||||
Assert.Contains(result.Errors,
|
||||
e => e.Category == ValidationCategory.TriggerOperandType
|
||||
&& e.Message.Contains("LoLo")
|
||||
&& e.Message.Contains("Lo"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_HiLoHiGreaterThanHiHi_ReturnsError()
|
||||
{
|
||||
var config = HiLoConfig("Temp", "Double",
|
||||
"{\"attributeName\":\"Temp\",\"hi\":120,\"hiHi\":100}");
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
|
||||
Assert.Contains(result.Errors,
|
||||
e => e.Category == ValidationCategory.TriggerOperandType
|
||||
&& e.Message.Contains("Hi")
|
||||
&& e.Message.Contains("HiHi"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_HiLoLowSideOverlapsHighSide_ReturnsError()
|
||||
{
|
||||
// Lo (50) >= Hi (40) — bands overlap.
|
||||
var config = HiLoConfig("Temp", "Double",
|
||||
"{\"attributeName\":\"Temp\",\"lo\":50,\"hi\":40}");
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
|
||||
Assert.Contains(result.Errors,
|
||||
e => e.Category == ValidationCategory.TriggerOperandType
|
||||
&& e.Message.Contains("overlap"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_HiLoOnlyHighSideConfigured_NoOrderingError()
|
||||
{
|
||||
// Only Hi/HiHi configured — no low-side comparison needed.
|
||||
var config = HiLoConfig("Temp", "Double",
|
||||
"{\"attributeName\":\"Temp\",\"hi\":80,\"hiHi\":100}");
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
|
||||
Assert.DoesNotContain(result.Errors,
|
||||
e => e.Category == ValidationCategory.TriggerOperandType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_HiLoNegativeDeadband_ReturnsError()
|
||||
{
|
||||
var config = HiLoConfig("Temp", "Double",
|
||||
"{\"attributeName\":\"Temp\",\"hi\":80,\"hiHi\":100,\"hiDeadband\":-1}");
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
|
||||
Assert.Contains(result.Errors,
|
||||
e => e.Category == ValidationCategory.TriggerOperandType
|
||||
&& e.Message.Contains("Hi deadband")
|
||||
&& e.Message.Contains("non-negative"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_HiLoZeroDeadband_NoError()
|
||||
{
|
||||
// Zero deadband is the default (no hysteresis) and must be accepted.
|
||||
var config = HiLoConfig("Temp", "Double",
|
||||
"{\"attributeName\":\"Temp\",\"hi\":80,\"hiHi\":100,\"hiDeadband\":0,\"hiHiDeadband\":0}");
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
|
||||
Assert.DoesNotContain(result.Errors,
|
||||
e => e.Category == ValidationCategory.TriggerOperandType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_HiLoValidOrdering_NoErrors()
|
||||
{
|
||||
// LoLo (-10) < Lo (0) < Hi (90) < HiHi (100) — fully valid.
|
||||
var config = HiLoConfig("Temp", "Double",
|
||||
"{\"attributeName\":\"Temp\",\"loLo\":-10,\"lo\":0,\"hi\":90,\"hiHi\":100}");
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
|
||||
Assert.DoesNotContain(result.Errors,
|
||||
e => e.Category == ValidationCategory.TriggerOperandType);
|
||||
Assert.DoesNotContain(result.Warnings,
|
||||
w => w.Category == ValidationCategory.TriggerOperandType);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user