Task #245 — exposes each scripted alarm's current ActiveState as IReadable so OPC UA variable reads on Source=ScriptedAlarm nodes return the live predicate truth instead of BadNotFound. ## ScriptedAlarmReadable Wraps ScriptedAlarmEngine + implements IReadable: - Known alarm + Active → DataValueSnapshot(true, Good) - Known alarm + Inactive → DataValueSnapshot(false, Good) - Unknown alarm id → DataValueSnapshot(null, BadNodeIdUnknown) — surfaces misconfiguration rather than silently reading false - Batch reads preserve request order Phase7EngineComposer.Compose now returns this as ScriptedAlarmReadable when ScriptedAlarm rows are present. ScriptedAlarmSource (IAlarmSource for the event stream) stays in place — the IReadable is a separate adapter over the same engine. ## Tests — 6 new + 1 updated composer test = 19 total Phase 7 tests ScriptedAlarmReadableTests covers: inactive + active predicate → bool snapshot, unknown alarm id → BadNodeIdUnknown, batch order preservation, null-engine + null-fullReferences guards. The active-predicate test uses ctx.GetTag on a seeded upstream value to drive a real cascade through the engine. Updated Phase7EngineComposerTests to assert ScriptedAlarmReadable is non-null when alarms compose, null when only virtual tags. ## Follow-ups remaining - #244 — driver-bridge feed populating CachedTagUpstreamSource - #246 — Program.cs Compose call + SqliteStoreAndForwardSink lifecycle
209 lines
9.5 KiB
C#
209 lines
9.5 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
|
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
|
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Server.Phase7;
|
|
|
|
/// <summary>
|
|
/// Phase 7 follow-up (task #243) — maps the generation's <see cref="Script"/> /
|
|
/// <see cref="VirtualTag"/> / <see cref="ScriptedAlarm"/> rows into the runtime
|
|
/// definitions <see cref="VirtualTagEngine"/> + <see cref="ScriptedAlarmEngine"/>
|
|
/// expect, builds the engine instances, and returns the <see cref="IReadable"/>
|
|
/// sources plus an <see cref="IAlarmSource"/> for the <c>DriverNodeManager</c>
|
|
/// wiring added by task #239.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Empty Phase 7 config (no virtual tags + no scripted alarms) is a valid state:
|
|
/// <see cref="Compose"/> returns a <see cref="Phase7ComposedSources"/> with null
|
|
/// sources so Program.cs can pass them through to <c>OpcUaApplicationHost</c>
|
|
/// unchanged — deployments without scripts behave exactly as they did before
|
|
/// Phase 7.
|
|
/// </para>
|
|
/// <para>
|
|
/// The caller owns the returned <see cref="Phase7ComposedSources.Disposables"/>
|
|
/// and must dispose them on shutdown. Engine cascades + timer ticks run off
|
|
/// background threads until then.
|
|
/// </para>
|
|
/// </remarks>
|
|
public static class Phase7EngineComposer
|
|
{
|
|
public static Phase7ComposedSources Compose(
|
|
IReadOnlyList<Script> scripts,
|
|
IReadOnlyList<VirtualTag> virtualTags,
|
|
IReadOnlyList<ScriptedAlarm> scriptedAlarms,
|
|
CachedTagUpstreamSource upstream,
|
|
IAlarmStateStore alarmStateStore,
|
|
IAlarmHistorianSink historianSink,
|
|
Serilog.ILogger rootScriptLogger,
|
|
ILoggerFactory loggerFactory)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(scripts);
|
|
ArgumentNullException.ThrowIfNull(virtualTags);
|
|
ArgumentNullException.ThrowIfNull(scriptedAlarms);
|
|
ArgumentNullException.ThrowIfNull(upstream);
|
|
ArgumentNullException.ThrowIfNull(alarmStateStore);
|
|
ArgumentNullException.ThrowIfNull(historianSink);
|
|
ArgumentNullException.ThrowIfNull(rootScriptLogger);
|
|
ArgumentNullException.ThrowIfNull(loggerFactory);
|
|
|
|
if (virtualTags.Count == 0 && scriptedAlarms.Count == 0)
|
|
return Phase7ComposedSources.Empty;
|
|
|
|
var scriptById = scripts
|
|
.Where(s => s.Enabled())
|
|
.ToDictionary(s => s.ScriptId, StringComparer.Ordinal);
|
|
|
|
var scriptLoggerFactory = new ScriptLoggerFactory(rootScriptLogger);
|
|
var disposables = new List<IDisposable>();
|
|
|
|
// Engines take Serilog.ILogger — each engine gets its own so rolling-file emissions
|
|
// stay keyed to the right source in the scripts-*.log.
|
|
VirtualTagSource? vtSource = null;
|
|
if (virtualTags.Count > 0)
|
|
{
|
|
var vtDefs = ProjectVirtualTags(virtualTags, scriptById).ToList();
|
|
var vtEngine = new VirtualTagEngine(upstream, scriptLoggerFactory, rootScriptLogger);
|
|
vtEngine.Load(vtDefs);
|
|
vtSource = new VirtualTagSource(vtEngine);
|
|
disposables.Add(vtEngine);
|
|
}
|
|
|
|
IReadable? alarmReadable = null;
|
|
if (scriptedAlarms.Count > 0)
|
|
{
|
|
var alarmDefs = ProjectScriptedAlarms(scriptedAlarms, scriptById).ToList();
|
|
var alarmEngine = new ScriptedAlarmEngine(upstream, alarmStateStore, scriptLoggerFactory, rootScriptLogger);
|
|
// Wire alarm emissions to the historian sink (Stream D). Fire-and-forget because
|
|
// the sink's EnqueueAsync is already non-blocking from the producer's view.
|
|
var engineLogger = loggerFactory.CreateLogger("Phase7HistorianRouter");
|
|
alarmEngine.OnEvent += (_, e) => _ = RouteToHistorianAsync(e, historianSink, engineLogger);
|
|
alarmEngine.LoadAsync(alarmDefs, CancellationToken.None).GetAwaiter().GetResult();
|
|
var alarmSource = new ScriptedAlarmSource(alarmEngine);
|
|
// Task #245 — expose each alarm's current Active state as IReadable so OPC UA
|
|
// variable reads on Source=ScriptedAlarm nodes return the live predicate truth
|
|
// instead of BadNotFound. ScriptedAlarmSource stays registered as IAlarmSource
|
|
// for the event stream; the IReadable is a separate adapter over the same engine.
|
|
alarmReadable = new ScriptedAlarmReadable(alarmEngine);
|
|
disposables.Add(alarmEngine);
|
|
disposables.Add(alarmSource);
|
|
}
|
|
|
|
return new Phase7ComposedSources(vtSource, alarmReadable, disposables);
|
|
}
|
|
|
|
internal static IEnumerable<VirtualTagDefinition> ProjectVirtualTags(
|
|
IReadOnlyList<VirtualTag> rows, IReadOnlyDictionary<string, Script> scriptById)
|
|
{
|
|
foreach (var row in rows)
|
|
{
|
|
if (!row.Enabled) continue;
|
|
if (!scriptById.TryGetValue(row.ScriptId, out var script))
|
|
throw new InvalidOperationException(
|
|
$"VirtualTag '{row.VirtualTagId}' references unknown / disabled Script '{row.ScriptId}' in this generation");
|
|
|
|
yield return new VirtualTagDefinition(
|
|
Path: row.VirtualTagId,
|
|
DataType: ParseDataType(row.DataType),
|
|
ScriptSource: script.SourceCode,
|
|
ChangeTriggered: row.ChangeTriggered,
|
|
TimerInterval: row.TimerIntervalMs.HasValue
|
|
? TimeSpan.FromMilliseconds(row.TimerIntervalMs.Value)
|
|
: null,
|
|
Historize: row.Historize);
|
|
}
|
|
}
|
|
|
|
internal static IEnumerable<ScriptedAlarmDefinition> ProjectScriptedAlarms(
|
|
IReadOnlyList<ScriptedAlarm> rows, IReadOnlyDictionary<string, Script> scriptById)
|
|
{
|
|
foreach (var row in rows)
|
|
{
|
|
if (!row.Enabled) continue;
|
|
if (!scriptById.TryGetValue(row.PredicateScriptId, out var script))
|
|
throw new InvalidOperationException(
|
|
$"ScriptedAlarm '{row.ScriptedAlarmId}' references unknown / disabled predicate Script '{row.PredicateScriptId}'");
|
|
|
|
yield return new ScriptedAlarmDefinition(
|
|
AlarmId: row.ScriptedAlarmId,
|
|
EquipmentPath: row.EquipmentId,
|
|
AlarmName: row.Name,
|
|
Kind: ParseAlarmKind(row.AlarmType),
|
|
Severity: MapSeverity(row.Severity),
|
|
MessageTemplate: row.MessageTemplate,
|
|
PredicateScriptSource: script.SourceCode,
|
|
HistorizeToAveva: row.HistorizeToAveva,
|
|
Retain: row.Retain);
|
|
}
|
|
}
|
|
|
|
private static DriverDataType ParseDataType(string raw) =>
|
|
Enum.TryParse<DriverDataType>(raw, ignoreCase: true, out var parsed) ? parsed : DriverDataType.String;
|
|
|
|
private static AlarmKind ParseAlarmKind(string raw) => raw switch
|
|
{
|
|
"AlarmCondition" => AlarmKind.AlarmCondition,
|
|
"LimitAlarm" => AlarmKind.LimitAlarm,
|
|
"DiscreteAlarm" => AlarmKind.DiscreteAlarm,
|
|
"OffNormalAlarm" => AlarmKind.OffNormalAlarm,
|
|
_ => throw new InvalidOperationException($"Unknown AlarmType '{raw}' — DB check constraint should have caught this"),
|
|
};
|
|
|
|
// OPC UA Part 9 severity bands (1..1000) → AlarmSeverity enum. Matches the same
|
|
// banding the AB CIP ALMA projection + OpcUaClient MapSeverity use.
|
|
private static AlarmSeverity MapSeverity(int s) => s switch
|
|
{
|
|
<= 250 => AlarmSeverity.Low,
|
|
<= 500 => AlarmSeverity.Medium,
|
|
<= 750 => AlarmSeverity.High,
|
|
_ => AlarmSeverity.Critical,
|
|
};
|
|
|
|
private static async Task RouteToHistorianAsync(
|
|
ScriptedAlarmEvent e, IAlarmHistorianSink sink, Microsoft.Extensions.Logging.ILogger log)
|
|
{
|
|
try
|
|
{
|
|
var historianEvent = new AlarmHistorianEvent(
|
|
AlarmId: e.AlarmId,
|
|
EquipmentPath: e.EquipmentPath,
|
|
AlarmName: e.AlarmName,
|
|
AlarmTypeName: e.Kind.ToString(),
|
|
Severity: e.Severity,
|
|
EventKind: e.Emission.ToString(),
|
|
Message: e.Message,
|
|
User: e.Condition.LastAckUser ?? "system",
|
|
Comment: e.Condition.LastAckComment,
|
|
TimestampUtc: e.TimestampUtc);
|
|
await sink.EnqueueAsync(historianEvent, CancellationToken.None).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
log.LogWarning(ex, "Historian enqueue failed for alarm {AlarmId}/{Emission}", e.AlarmId, e.Emission);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>What <see cref="Phase7EngineComposer.Compose"/> returns.</summary>
|
|
/// <param name="VirtualReadable">Non-null when virtual tags were composed; pass to <c>OpcUaApplicationHost.virtualReadable</c>.</param>
|
|
/// <param name="ScriptedAlarmReadable">Non-null when scripted alarms were composed; pass to <c>OpcUaApplicationHost.scriptedAlarmReadable</c>.</param>
|
|
/// <param name="Disposables">Engine + source instances the caller owns. Dispose on shutdown.</param>
|
|
public sealed record Phase7ComposedSources(
|
|
IReadable? VirtualReadable,
|
|
IReadable? ScriptedAlarmReadable,
|
|
IReadOnlyList<IDisposable> Disposables)
|
|
{
|
|
public static readonly Phase7ComposedSources Empty =
|
|
new(null, null, Array.Empty<IDisposable>());
|
|
}
|
|
|
|
internal static class ScriptEnabledExtensions
|
|
{
|
|
// Script has no explicit Enabled column; every row in the generation is a live script.
|
|
public static bool Enabled(this Script _) => true;
|
|
}
|