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:
Joseph Doherty
2026-05-13 03:23:32 -04:00
parent 783da8e21a
commit 751248feb6
46 changed files with 4693 additions and 204 deletions

92
docker/regen-proto.sh Executable file
View 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/"

View File

@@ -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.

View File

@@ -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.

View File

@@ -106,16 +106,18 @@ Each alarm has:
- **Priority Level**: Numeric value from 01000.
- **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

View File

@@ -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 };

View File

@@ -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)

View File

@@ -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">

View File

@@ -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; }
}

View File

@@ -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());
}
}

View File

@@ -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)

View File

@@ -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));
}
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;
}

View 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 &lt; Low &lt; normal-band &lt; High &lt; HighHigh
/// </summary>
public enum AlarmLevel
{
None,
Low,
LowLow,
High,
HighHigh
}

View File

@@ -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
}

View 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;
}

View File

@@ -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
};
}

View File

@@ -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)

View File

@@ -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.
}

View File

@@ -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>

View File

@@ -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;
}
}
}
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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");
}
}
}

View File

@@ -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");

View File

@@ -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);
}

View File

@@ -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)

View File

@@ -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>();

View File

@@ -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>

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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

View File

@@ -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,

View File

@@ -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>

View File

@@ -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))

View File

@@ -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);

View File

@@ -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));
}
}

View File

@@ -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");
}
}

View File

@@ -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)

View File

@@ -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()
{

View File

@@ -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));
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}
}