diff --git a/src/ScadaLink.SiteRuntime/Actors/AlarmActor.cs b/src/ScadaLink.SiteRuntime/Actors/AlarmActor.cs index e425c8b..aa3ad70 100644 --- a/src/ScadaLink.SiteRuntime/Actors/AlarmActor.cs +++ b/src/ScadaLink.SiteRuntime/Actors/AlarmActor.cs @@ -50,6 +50,12 @@ public class AlarmActor : ReceiveActor private readonly string? _onTriggerScriptName; private readonly Script? _onTriggerCompiledScript; + // Expression trigger: compiled expression + the attribute snapshot it + // evaluates against. The compiled expression is also held on the + // ExpressionEvalConfig; this field caches it for the hot path. + private readonly Script? _compiledTriggerExpression; + private readonly Dictionary _attributeSnapshot = new(); + // Rate of change tracking private readonly Queue<(DateTimeOffset Timestamp, double Value)> _rateOfChangeWindow = new(); private readonly TimeSpan _rateOfChangeWindowDuration; @@ -65,6 +71,7 @@ public class AlarmActor : ReceiveActor SharedScriptLibrary sharedScriptLibrary, SiteRuntimeOptions options, ILogger logger, + Script? compiledTriggerExpression = null, ISiteHealthCollector? healthCollector = null) { _alarmName = alarmName; @@ -77,6 +84,7 @@ public class AlarmActor : ReceiveActor _priority = alarmConfig.PriorityLevel; _onTriggerScriptName = alarmConfig.OnTriggerScriptCanonicalName; _onTriggerCompiledScript = onTriggerCompiledScript; + _compiledTriggerExpression = compiledTriggerExpression; // Parse trigger type _triggerType = Enum.TryParse(alarmConfig.TriggerType, true, out var tt) @@ -126,9 +134,18 @@ public class AlarmActor : ReceiveActor /// private void HandleAttributeValueChanged(AttributeValueChanged changed) { - // Only evaluate if this change is for an attribute we're monitoring - if (!IsMonitoredAttribute(changed.AttributeName)) + // Expression triggers evaluate against a snapshot of every attribute, + // not a single monitored attribute. Keep the snapshot current for every + // change before the IsMonitoredAttribute gate (which does not apply). + if (_triggerType == AlarmTriggerType.Expression) + { + _attributeSnapshot[changed.AttributeName] = changed.Value; + } + else if (!IsMonitoredAttribute(changed.AttributeName)) + { + // Only evaluate if this change is for an attribute we're monitoring return; + } try { @@ -143,6 +160,7 @@ public class AlarmActor : ReceiveActor AlarmTriggerType.ValueMatch => EvaluateValueMatch(changed.Value), AlarmTriggerType.RangeViolation => EvaluateRangeViolation(changed.Value), AlarmTriggerType.RateOfChange => EvaluateRateOfChange(changed.Value, changed.Timestamp), + AlarmTriggerType.Expression => EvaluateExpression(), _ => false }; @@ -337,6 +355,22 @@ public class AlarmActor : ReceiveActor } } + /// + /// Evaluates the compiled trigger expression against the current attribute + /// snapshot, returning the resulting bool. This bool feeds the existing + /// binary Normal↔Active state path — the alarm is active while true. A + /// throwing or non-bool expression is treated as false; the exception + /// propagates to the caller's catch, which logs it and continues. + /// + private bool EvaluateExpression() + { + if (_compiledTriggerExpression == null) return false; + + var globals = new TriggerExpressionGlobals(_attributeSnapshot); + var state = _compiledTriggerExpression.RunAsync(globals).GetAwaiter().GetResult(); + return state.ReturnValue is bool b && b; + } + /// /// HiLo level evaluator: returns the most-severe matching band for the /// given value. Severity order checked from highest to lowest so that a @@ -473,6 +507,14 @@ public class AlarmActor : ReceiveActor HiMessage: TryReadString(root, "hiMessage"), HiHiMessage: TryReadString(root, "hiHiMessage")), + // Expression triggers have no single monitored attribute; they + // evaluate the compiled expression (passed into the actor) over + // the full attribute snapshot. MonitoredAttributeName is unused. + AlarmTriggerType.Expression => new ExpressionEvalConfig( + "", + TryReadString(root, "expression") ?? "", + _compiledTriggerExpression), + _ => new ValueMatchEvalConfig(attr, null) }; } @@ -535,6 +577,17 @@ internal record RateOfChangeEvalConfig( TimeSpan WindowDuration, RateOfChangeDirection Direction) : AlarmEvalConfig(MonitoredAttributeName); +/// +/// Expression evaluation config: a read-only boolean C# expression evaluated +/// over the full attribute snapshot. Has no single monitored attribute +/// ( is empty); the +/// compiled expression is passed into the actor and cached here. +/// +internal record ExpressionEvalConfig( + string MonitoredAttributeName, + string Expression, + Script? CompiledExpression) : AlarmEvalConfig(MonitoredAttributeName); + /// /// HiLo evaluation config: any subset of the four setpoints may be set; null /// means "don't evaluate that band". Per-setpoint priorities override the diff --git a/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs b/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs index 68a230b..f3fb530 100644 --- a/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs +++ b/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs @@ -515,6 +515,10 @@ public class InstanceActor : ReceiveActor continue; } + // Compile the trigger expression for Expression-triggered scripts. + var triggerExpression = CompileTriggerExpression( + script.TriggerType, script.TriggerConfiguration, $"script-trigger-{script.CanonicalName}"); + var props = Props.Create(() => new ScriptActor( script.CanonicalName, _instanceUniqueName, @@ -524,6 +528,7 @@ public class InstanceActor : ReceiveActor _sharedScriptLibrary, _options, _logger, + triggerExpression, _healthCollector, _serviceProvider)); @@ -559,6 +564,10 @@ public class InstanceActor : ReceiveActor } } + // Compile the trigger expression for Expression-triggered alarms. + var triggerExpression = CompileTriggerExpression( + alarm.TriggerType, alarm.TriggerConfiguration, $"alarm-trigger-expr-{alarm.CanonicalName}"); + var props = Props.Create(() => new AlarmActor( alarm.CanonicalName, _instanceUniqueName, @@ -568,6 +577,7 @@ public class InstanceActor : ReceiveActor _sharedScriptLibrary, _options, _logger, + triggerExpression, _healthCollector)); var actorRef = Context.ActorOf(props, $"alarm-{alarm.CanonicalName}"); @@ -581,6 +591,47 @@ public class InstanceActor : ReceiveActor _instanceUniqueName, _scriptActors.Count, _alarmActors.Count); } + /// + /// Compiles the boolean trigger expression for an Expression-triggered + /// script or alarm. Returns null for non-Expression triggers, a blank + /// expression, or a compilation failure (logged) — in which case the + /// trigger is inert and the actor still starts. + /// + private Microsoft.CodeAnalysis.Scripting.Script? CompileTriggerExpression( + string? triggerType, string? triggerConfigJson, string compileName) + { + if (!string.Equals(triggerType, "Expression", StringComparison.OrdinalIgnoreCase)) + return null; + if (string.IsNullOrEmpty(triggerConfigJson)) + return null; + + string? expression; + try + { + var doc = JsonSerializer.Deserialize(triggerConfigJson); + expression = doc.TryGetProperty("expression", out var e) ? e.GetString() : null; + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Failed to read trigger expression config for {Name} on {Instance}", + compileName, _instanceUniqueName); + return null; + } + + if (string.IsNullOrWhiteSpace(expression)) + return null; + + var result = _compilationService.CompileTriggerExpression(compileName, expression); + if (result.IsSuccess) + return result.CompiledScript; + + _logger.LogError( + "Trigger expression for {Name} on {Instance} failed to compile: {Errors}", + compileName, _instanceUniqueName, string.Join("; ", result.Errors)); + return null; + } + /// /// Read-only access to current attribute count (for testing/diagnostics). /// diff --git a/src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs b/src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs index 0af75c2..9435397 100644 --- a/src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs +++ b/src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs @@ -1,10 +1,12 @@ using Akka.Actor; using Microsoft.CodeAnalysis.Scripting; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using ScadaLink.Commons.Messages.ScriptExecution; using ScadaLink.Commons.Messages.Streaming; using ScadaLink.Commons.Types.Flattening; using ScadaLink.HealthMonitoring; +using ScadaLink.SiteEventLogging; using ScadaLink.SiteRuntime.Scripts; using System.Text.Json; @@ -40,6 +42,12 @@ public class ScriptActor : ReceiveActor, IWithTimers private int _executionCounter; private readonly Commons.Types.Scripts.ScriptScope _scope; + // Expression trigger state: compiled expression, edge-tracking, and the + // attribute snapshot the expression evaluates against. + private readonly Script? _compiledTriggerExpression; + private bool _lastExpressionResult; + private readonly Dictionary _attributeSnapshot = new(); + public ITimerScheduler Timers { get; set; } = null!; public ScriptActor( @@ -51,6 +59,7 @@ public class ScriptActor : ReceiveActor, IWithTimers SharedScriptLibrary sharedScriptLibrary, SiteRuntimeOptions options, ILogger logger, + Script? compiledTriggerExpression = null, ISiteHealthCollector? healthCollector = null, IServiceProvider? serviceProvider = null) { @@ -65,6 +74,7 @@ public class ScriptActor : ReceiveActor, IWithTimers _serviceProvider = serviceProvider; _minTimeBetweenRuns = scriptConfig.MinTimeBetweenRuns; _scope = scriptConfig.Scope; + _compiledTriggerExpression = compiledTriggerExpression; // Parse trigger configuration _triggerConfig = ParseTriggerConfig(scriptConfig.TriggerType, scriptConfig.TriggerConfiguration); @@ -143,10 +153,15 @@ public class ScriptActor : ReceiveActor, IWithTimers } /// - /// Handles attribute value changes — triggers script if configured for value-change or conditional. + /// Handles attribute value changes — triggers script if configured for + /// value-change, conditional, or expression. The attribute snapshot is + /// updated for every change before any trigger logic runs. /// private void HandleAttributeValueChanged(AttributeValueChanged changed) { + // Keep the snapshot current for every change, regardless of trigger type. + _attributeSnapshot[changed.AttributeName] = changed.Value; + if (_triggerConfig is ValueChangeTriggerConfig valueTrigger) { if (valueTrigger.AttributeName == changed.AttributeName) @@ -165,6 +180,55 @@ public class ScriptActor : ReceiveActor, IWithTimers } } } + else if (_triggerConfig is ExpressionTriggerConfig) + { + EvaluateExpressionTrigger(); + } + } + + /// + /// Evaluates the compiled trigger expression against the current attribute + /// snapshot and runs the script edge-triggered — once per false→true + /// transition. A throwing or non-bool expression is treated as false and + /// logged as a script error; the actor never crashes. + /// + private void EvaluateExpressionTrigger() + { + if (_compiledTriggerExpression == null) return; + + bool result; + try + { + var globals = new TriggerExpressionGlobals(_attributeSnapshot); + var state = _compiledTriggerExpression.RunAsync(globals).GetAwaiter().GetResult(); + result = state.ReturnValue is bool b && b; + } + catch (Exception ex) + { + LogExpressionError(ex); + result = false; + } + + if (result && !_lastExpressionResult) + { + TrySpawnExecution(null); + } + + _lastExpressionResult = result; + } + + /// + /// Records a trigger-expression evaluation failure to the site event log, + /// mirroring how ScriptExecutionActor reports script errors. + /// + private void LogExpressionError(Exception ex) + { + _healthCollector?.IncrementScriptError(); + var errorMsg = $"Trigger expression for script '{_scriptName}' on instance '{_instanceName}' failed: {ex.Message}"; + _logger.LogError(ex, "Trigger expression evaluation failed: {Script} on {Instance}", _scriptName, _instanceName); + + _ = _serviceProvider?.GetService()?.LogEventAsync( + "script", "Error", _instanceName, $"ScriptActor:{_scriptName}", errorMsg, ex.ToString()); } /// @@ -264,11 +328,26 @@ public class ScriptActor : ReceiveActor, IWithTimers "interval" => ParseIntervalTrigger(triggerConfigJson), "valuechange" => ParseValueChangeTrigger(triggerConfigJson), "conditional" => ParseConditionalTrigger(triggerConfigJson), + "expression" => ParseExpressionTrigger(triggerConfigJson), "call" => null, // No automatic trigger — invoked only via Instance.CallScript() _ => null }; } + private static ExpressionTriggerConfig? ParseExpressionTrigger(string? json) + { + if (string.IsNullOrEmpty(json)) return null; + try + { + var doc = JsonDocument.Parse(json); + var expr = doc.RootElement.TryGetProperty("expression", out var e) + ? e.GetString() + : null; + return string.IsNullOrWhiteSpace(expr) ? null : new ExpressionTriggerConfig(expr); + } + catch { return null; } + } + private static IntervalTriggerConfig? ParseIntervalTrigger(string? json) { if (string.IsNullOrEmpty(json)) return null; @@ -323,4 +402,5 @@ public class ScriptActor : ReceiveActor, IWithTimers internal record IntervalTriggerConfig(TimeSpan Interval) : ScriptTriggerConfig; internal record ValueChangeTriggerConfig(string AttributeName) : ScriptTriggerConfig; internal record ConditionalTriggerConfig(string AttributeName, string Operator, double Threshold) : ScriptTriggerConfig; +internal record ExpressionTriggerConfig(string Expression) : ScriptTriggerConfig; internal abstract record ScriptTriggerConfig; diff --git a/src/ScadaLink.SiteRuntime/Scripts/ScriptCompilationService.cs b/src/ScadaLink.SiteRuntime/Scripts/ScriptCompilationService.cs index bb3442b..1056d34 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/ScriptCompilationService.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/ScriptCompilationService.cs @@ -87,11 +87,45 @@ public class ScriptCompilationService return violations; } + /// + /// Shared Roslyn scripting options (references + imports) used by both full + /// script compilation and trigger-expression compilation. + /// + private static ScriptOptions BuildScriptOptions() => ScriptOptions.Default + .WithReferences( + typeof(object).Assembly, + typeof(Enumerable).Assembly, + typeof(Math).Assembly, + typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly, + typeof(Commons.Types.DynamicJsonElement).Assembly) + .WithImports( + "System", + "System.Collections.Generic", + "System.Linq", + "System.Threading.Tasks"); + /// /// Compiles a script into a reusable delegate that takes a ScriptRuntimeContext /// and parameters dictionary, and returns an object? result. /// public ScriptCompilationResult Compile(string scriptName, string code) + => CompileCore(scriptName, code, typeof(ScriptGlobals)); + + /// + /// Compiles a bare C# boolean trigger expression against the restricted + /// read-only . The expression is a + /// trailing expression (no return); Roslyn scripting yields its + /// value, which the caller coerces to bool. Reuses the same script + /// options and forbidden-API trust validation as . + /// + public ScriptCompilationResult CompileTriggerExpression(string name, string expression) + => CompileCore(name, expression, typeof(TriggerExpressionGlobals)); + + /// + /// Shared compilation path: validates the trust model, builds the script + /// against the given globals type, and returns the compiled result. + /// + private ScriptCompilationResult CompileCore(string name, string code, Type globalsType) { // Validate trust model var violations = ValidateTrustModel(code); @@ -99,29 +133,16 @@ public class ScriptCompilationService { _logger.LogWarning( "Script {Script} failed trust validation: {Violations}", - scriptName, string.Join("; ", violations)); + name, string.Join("; ", violations)); return ScriptCompilationResult.Failed(violations); } try { - var scriptOptions = ScriptOptions.Default - .WithReferences( - typeof(object).Assembly, - typeof(Enumerable).Assembly, - typeof(Math).Assembly, - typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly, - typeof(Commons.Types.DynamicJsonElement).Assembly) - .WithImports( - "System", - "System.Collections.Generic", - "System.Linq", - "System.Threading.Tasks"); - var script = CSharpScript.Create( code, - scriptOptions, - globalsType: typeof(ScriptGlobals)); + BuildScriptOptions(), + globalsType: globalsType); var diagnostics = script.Compile(); var errors = diagnostics @@ -133,16 +154,16 @@ public class ScriptCompilationService { _logger.LogWarning( "Script {Script} compilation failed: {Errors}", - scriptName, string.Join("; ", errors)); + name, string.Join("; ", errors)); return ScriptCompilationResult.Failed(errors); } - _logger.LogDebug("Script {Script} compiled successfully", scriptName); + _logger.LogDebug("Script {Script} compiled successfully", name); return ScriptCompilationResult.Succeeded(script); } catch (Exception ex) { - _logger.LogError(ex, "Unexpected error compiling script {Script}", scriptName); + _logger.LogError(ex, "Unexpected error compiling script {Script}", name); return ScriptCompilationResult.Failed([$"Compilation exception: {ex.Message}"]); } } diff --git a/src/ScadaLink.SiteRuntime/Scripts/TriggerExpressionGlobals.cs b/src/ScadaLink.SiteRuntime/Scripts/TriggerExpressionGlobals.cs new file mode 100644 index 0000000..ddf1dc5 --- /dev/null +++ b/src/ScadaLink.SiteRuntime/Scripts/TriggerExpressionGlobals.cs @@ -0,0 +1,74 @@ +namespace ScadaLink.SiteRuntime.Scripts; + +/// +/// Read-only globals a trigger expression is compiled against. Exposes only +/// attribute reads, backed by an in-memory snapshot — no I/O, no actor Ask, +/// no side-effecting APIs. A missing attribute key reads as null and +/// never throws. +/// +/// Canonical attribute keys are dotted (e.g. "TempSensor.Reading"); the prefix +/// logic here mirrors . +/// +public sealed class TriggerExpressionGlobals +{ + private readonly IReadOnlyDictionary _snapshot; + + public TriggerExpressionGlobals(IReadOnlyDictionary snapshot) + => _snapshot = snapshot; + + /// Attributes in the expression's own scope (root prefix). + public ReadOnlyAttributes Attributes => new(_snapshot, ""); + + /// Indexed access to child compositions' attributes. + public ReadOnlyChildren Children => new(_snapshot); + + /// + /// Parent composition (null at root). Set by the caller for derived/composed + /// scopes; the runtime actors evaluate at root scope, so this stays null. + /// + public ReadOnlyComposition? Parent { get; init; } + + /// + /// Read-only attribute view anchored at a canonical-name prefix. Indexing + /// resolves to the canonical key ("" → key, "TempSensor" → "TempSensor.key"). + /// + public sealed class ReadOnlyAttributes + { + private readonly IReadOnlyDictionary _s; + private readonly string _prefix; + + public ReadOnlyAttributes(IReadOnlyDictionary s, string prefix) + { + _s = s; + _prefix = prefix; + } + + public object? this[string key] => + _s.TryGetValue(_prefix.Length == 0 ? key : _prefix + "." + key, out var v) ? v : null; + } + + /// A read-only view of one composition at a canonical-name path. + public sealed class ReadOnlyComposition + { + private readonly IReadOnlyDictionary _s; + private readonly string _path; + + public ReadOnlyComposition(IReadOnlyDictionary s, string path) + { + _s = s; + _path = path; + } + + public ReadOnlyAttributes Attributes => new(_s, _path); + } + + /// Dictionary-style accessor for child compositions. + public sealed class ReadOnlyChildren + { + private readonly IReadOnlyDictionary _s; + + public ReadOnlyChildren(IReadOnlyDictionary s) => _s = s; + + public ReadOnlyComposition this[string compositionName] => new(_s, compositionName); + } +}