b61f3e0560
v2-ci / unit-tests (push) Has been cancelled
GalaxyAlarmPriority, the one table live Galaxy alarms (MxAccessSeverityMapper) and AVEVA A&E history (EventMapper, #886) map the AVEVA priority through, ranked a higher number as more severe. AVEVA ranks priority 1 as the most urgent, so a priority-1 alarm read 250 and a priority-999 system event 1000. It now uses AVEVA's default priority ranges, most urgent first: AVEVA priority AVEVA severity bucket OPC UA Severity 1-250 1 Critical Critical 1000 251-500 2 High High 750 501-750 3 Medium Medium 500 751-999 4 Low Low 250 (history only) 0 not an alarm - 1 Out-of-range priorities clamp (<= 0 Critical, > 999 Low). Evidence, vd03 Runtime.dbo.Events read-only: priority 1/100 alarms carry AVEVA severity 1, 400/500 severity 2, 600/700 severity 3, and every priority-999 System.* / User.* row severity 0 with IsAlarm 0. - Live and history now use the same number. GalaxyDriver passes the table's exact value as AlarmEventArgs.OpcUaSeverity. Before, it passed only the bucket, which NativeAlarmProjector re-mapped through its generic 200/500/700/900 ladder, so live and history disagreed even before the direction fix. - History: a row whose AVEVA severity is 0 (System.Start/Stop/Deploy, User.Write...) is not an alarm and reads Severity 1, below every alarm band. For alarms AVEVA's 1-4 severity is still never the value: the live feed carries only the priority, so it cannot use it, and history must agree. - Docs: docs/Historian.md gains "Galaxy alarm priority -> Severity (lmxopcua#887)"; docs/AlarmTracking.md updated. - Live re-run of the #801/#886 harness against vd03 HistorianGateway ea9f21c (read-only, network-only): 224 of 233 cases identical to the #886 run, the 9 others differ only in Severity (priority-700 alarm rows 750 -> 500, 19 system/user events 1000 -> 1). Record: aveva-history-801.md "#887 re-run", report aveva-gate-sdk2-887.json.gz. Client-visible: every OPC UA client that ranks or filters Galaxy alarms by Severity sees the order reversed (live and HistoryReadEvents). ScadaBridge has no code that depends on the old values; its own mixed severity directions are ScadaBridge#129. Tests updated because they pinned the inverted table: - Driver.Galaxy.Tests MxAccessSeverityMapperTests (rewritten: edges 1/250/251/ 500/501/750/751/999, 0/-100/int.MinValue/1000/int.MaxValue clamps, monotonic-decreasing sweep, equals GalaxyAlarmPriority for -2..1001) - Driver.Galaxy.Tests GatewayGalaxyAlarmFeedTests (750 and 600 -> Medium) - Driver.Historian.Gateway.Tests EventMapperTests (band table, priority 999 -> 250, 1 -> 1000; system events -> 1) - OpcUaServer.IntegrationTests GalaxyEquipmentEventHistoryWireTests (700 -> 500, 100 -> 1000, System.Stop -> 1; the fake now stores AVEVA's real 0-4 severity) New tests: - Runtime.Tests NativeAlarmRestatementEndToEndTests .Galaxy_live_condition_severity_is_the_AVEVA_priority_band (8 rows: the real feed -> real GalaxyDriver -> host -> projector; the same rows EventMapperTests pins on the history path) - EventMapperTests.Aveva_severity_zero_marks_an_event_that_is_not_an_alarm (5 rows, incl. the priority-999 alarm positive control) - GalaxyDriverAlarmSourceTests: OpcUaSeverity passes through Mutation check, 8 mutants, all killed: the pre-#887 inverted table; the driver dropping OpcUaSeverity; no not-an-alarm rule; each of the three band edges off by one; history ignoring priority; a missing AVEVA severity read as 0. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
327 lines
19 KiB
C#
327 lines
19 KiB
C#
using System.Runtime.CompilerServices;
|
||
using Google.Protobuf.WellKnownTypes;
|
||
using Microsoft.Extensions.Logging.Abstractions;
|
||
using Opc.Ua;
|
||
using Opc.Ua.Client;
|
||
using Shouldly;
|
||
using Xunit;
|
||
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
|
||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway;
|
||
using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||
|
||
/// <summary>
|
||
/// Gitea #886 over a real SDK session, with the production Gateway backend on a deterministic AVEVA-shaped gateway: a
|
||
/// REAL equipment folder — whose id (<c>EqA</c>) is not a Galaxy object name — serves the AVEVA history of the Galaxy
|
||
/// objects behind its native conditions, here two (<c>Oven_007</c> and <c>OvenDoor_07</c>): each object's own events
|
||
/// and its attribute alarms, never a prefix sibling (<c>Oven_0070</c>), merged in time order across paging and reverse
|
||
/// reads, with the live condition's SourceNode / ConditionName and the live Galaxy severity, and only behind the
|
||
/// folder's own HistoryRead verdict.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Topology, as the applier builds it from a Galaxy deployment: raw folder <c>Plant/Galaxy/Oven7</c> holds two native
|
||
/// conditions, <c>TempHi</c> (attributeRef <c>Oven_007.TempHiAlarm</c>) and <c>DoorOpen</c>
|
||
/// (<c>OvenDoor_07.DoorAlarm</c>), both wired to equipment <c>EqA</c>; raw folder <c>Plant/Galaxy/Line3</c> holds
|
||
/// <c>Jam</c> (<c>Line3_Conveyor.JamAlarm</c>), wired to <c>EqB</c>. The fake gateway filters a per-source read more
|
||
/// loosely than the real one (<c>Source_Object</c> begins with the source), so the sibling's rows reach OtOpcUa and
|
||
/// their absence is OtOpcUa's filter at work.
|
||
/// </remarks>
|
||
public sealed class GalaxyEquipmentEventHistoryWireTests
|
||
{
|
||
private const string OvenFolder = "Plant/Galaxy/Oven7";
|
||
private const string LineFolder = "Plant/Galaxy/Line3";
|
||
private const string TempHi = OvenFolder + "/TempHi";
|
||
private const string DoorOpen = OvenFolder + "/DoorOpen";
|
||
private const string Jam = LineFolder + "/Jam";
|
||
|
||
private static readonly DateTime Epoch = new(2026, 11, 10, 11, 0, 0, DateTimeKind.Utc);
|
||
|
||
/// <summary>The stored AVEVA rows. A null condition variable is an object-level system event.</summary>
|
||
private static readonly AvevaRow[] Stored =
|
||
[
|
||
new("o-stop", "Oven_007", "Oven_007", "System.Stop", 0, 999, null),
|
||
new("l-set", "Line3_Conveyor.Belt", "Line3_Conveyor", "Alarm.Set", 1, 300, "Line3_Conveyor.JamAlarm"),
|
||
new("o-temp-set", "Oven_007.Temp", "Oven_007", "Alarm.Set", 2, 500, "Oven_007.TempHiAlarm"),
|
||
new("d-set", "OvenDoor_07.Door", "OvenDoor_07", "Alarm.Set", 2, 700, "OvenDoor_07.DoorAlarm"), // a tie across objects
|
||
new("a-oven-tie", "Oven_007.Temp", "Oven_007", "Alarm.Ack", 2, 500, "Oven_007.TempHiAlarm"), // and within one, stored later
|
||
new("o-press-set", "Oven_007.Pressure", "Oven_007", "Alarm.Set", 3, 100, "Oven_007.PressureHiAlarm"), // no live condition here
|
||
new("s-set", "Oven_0070.Temp", "Oven_0070", "Alarm.Set", 4, 500, "Oven_0070.TempHiAlarm"), // prefix sibling
|
||
new("d-clear", "OvenDoor_07.Door", "OvenDoor_07", "Alarm.Clear", 5, 700, "OvenDoor_07.DoorAlarm"),
|
||
new("o-temp-clear", "Oven_007.Temp", "Oven_007", "Alarm.Clear", 6, 500, "oven_007.temphialarm"), // Galaxy names: any case
|
||
new("l-clear", "Line3_Conveyor.Belt", "Line3_Conveyor", "Alarm.Clear", 7, 300, "Line3_Conveyor.JamAlarm"),
|
||
new("o-start", "Oven_007", "Oven_007", "System.Start", 8, 999, null),
|
||
];
|
||
|
||
/// <summary>EqA's events forward: by time, then source (the folder's ordinal-sorted list: <c>OvenDoor_07</c> before
|
||
/// <c>Oven_007</c>), then the order the gateway returned them in — so <c>a-oven-tie</c> follows <c>o-temp-set</c>.</summary>
|
||
private static readonly string[] EqAEvents =
|
||
["o-stop", "d-set", "o-temp-set", "a-oven-tie", "o-press-set", "d-clear", "o-temp-clear", "o-start"];
|
||
|
||
/// <summary>EqA's events reverse: newest first; the Gateway's walked-back reverse read orders ties by EventId,
|
||
/// descending, as it did before #886.</summary>
|
||
private static readonly string[] EqAEventsReverse =
|
||
["o-start", "o-temp-clear", "d-clear", "o-press-set", "o-temp-set", "d-set", "a-oven-tie", "o-stop"];
|
||
|
||
[Fact]
|
||
public async Task An_equipment_folder_serves_the_aveva_history_of_every_galaxy_object_behind_it()
|
||
{
|
||
var ct = TestContext.Current.CancellationToken;
|
||
await using var rig = await StartAsync("GalaxyEqHistory", encrypted: false, ct);
|
||
var gateway = new AvevaShapedGateway();
|
||
rig.Nm.HistorianDataSource = new GatewayHistorianDataSource(gateway, NullLogger<GatewayHistorianDataSource>.Instance);
|
||
Materialise(rig);
|
||
rig.Enforce(false, [Key()]);
|
||
|
||
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
||
var all = await ReadAsync(session, Uns(session, EqA), forward: true, page: 0, ct);
|
||
|
||
all.Status.ShouldBe(StatusCodes.Good);
|
||
all.Rows.Select(r => r.Id).ShouldBe(EqAEvents);
|
||
gateway.Requested.ShouldBe(new[] { EqA, "OvenDoor_07", "Oven_007" },
|
||
"the folder's own source and one request per Galaxy object behind it (no multi-source request exists)");
|
||
gateway.Yielded.ShouldContain("s-set", "the sibling's rows reached OtOpcUa, so their absence is its filter's doing");
|
||
|
||
var byId = all.Rows.ToDictionary(r => r.Id);
|
||
// Each event keeps the source the historian reported.
|
||
byId["o-temp-set"].Source.ShouldBe("Oven_007.Temp");
|
||
byId["o-stop"].Source.ShouldBe("Oven_007");
|
||
// A row naming a condition that fires on this folder carries that live condition (any-case reference).
|
||
byId["o-temp-set"].SourceNode.ShouldBe(TempHi);
|
||
byId["o-temp-clear"].SourceNode.ShouldBe(TempHi);
|
||
byId["o-temp-set"].ConditionName.ShouldBe("TempHi");
|
||
byId["d-set"].SourceNode.ShouldBe(DoorOpen);
|
||
byId["d-set"].ConditionName.ShouldBe("DoorOpen");
|
||
// An alarm of the object with no condition wired here: served (object rule), but names no node.
|
||
byId["o-press-set"].SourceNode.ShouldBeNull();
|
||
byId["o-press-set"].ConditionName.ShouldBe("PressureHiAlarm");
|
||
// A system event has no condition at all.
|
||
byId["o-stop"].SourceNode.ShouldBeNull();
|
||
byId["o-stop"].ConditionName.ShouldBeNull();
|
||
// Severity follows the live Galaxy table, AVEVA priority 1 = most urgent (lmxopcua#887): 500 → 750 (High),
|
||
// 700 → 500 (Medium), 100 → 1000 (Critical); a system event (priority 999, AVEVA severity 0) is not an alarm → 1.
|
||
byId["o-temp-set"].Severity.ShouldBe((ushort)750);
|
||
byId["d-set"].Severity.ShouldBe((ushort)500);
|
||
byId["o-press-set"].Severity.ShouldBe((ushort)1000);
|
||
byId["o-stop"].Severity.ShouldBe((ushort)1);
|
||
|
||
// The raw device folder the conditions hang under reads the same objects (live: a condition fires on its parent).
|
||
var raw = await ReadAsync(session, Raw(session, OvenFolder), forward: true, page: 0, ct);
|
||
raw.Rows.Select(r => r.Id).ShouldBe(EqAEvents);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Paging_and_reverse_reads_walk_the_merged_sources_exactly_once()
|
||
{
|
||
var ct = TestContext.Current.CancellationToken;
|
||
await using var rig = await StartAsync("GalaxyEqHistoryPaging", encrypted: false, ct);
|
||
var gateway = new AvevaShapedGateway();
|
||
rig.Nm.HistorianDataSource = new GatewayHistorianDataSource(gateway, NullLogger<GatewayHistorianDataSource>.Instance);
|
||
Materialise(rig);
|
||
rig.Enforce(false, [Key()]);
|
||
|
||
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
||
var node = Uns(session, EqA);
|
||
foreach (var page in new uint[] { 1, 2, 3, 4 })
|
||
{
|
||
var paged = await ReadAsync(session, node, forward: true, page, ct);
|
||
paged.Rows.Select(r => r.Id).ShouldBe(EqAEvents, $"forward, {page} a page");
|
||
paged.Pages.ShouldBeGreaterThan(1);
|
||
|
||
var reverse = await ReadAsync(session, node, forward: false, page, ct);
|
||
reverse.Rows.Select(r => r.Id).ShouldBe(EqAEventsReverse, $"reverse, {page} a page (newest first, ties descending)");
|
||
}
|
||
|
||
var reverseWhole = await ReadAsync(session, node, forward: false, page: 0, ct);
|
||
reverseWhole.Rows.Select(r => r.Id).ShouldBe(EqAEventsReverse);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task With_the_acl_on_a_denied_folder_reads_none_of_its_galaxy_objects()
|
||
{
|
||
var ct = TestContext.Current.CancellationToken;
|
||
await using var rig = await StartAsync("GalaxyEqHistoryAcl", encrypted: false, ct);
|
||
var gateway = new AvevaShapedGateway();
|
||
rig.Nm.HistorianDataSource = new GatewayHistorianDataSource(gateway, NullLogger<GatewayHistorianDataSource>.Instance);
|
||
Materialise(rig);
|
||
// Anonymous is granted EqB only: EqA, behind which sit Oven_007 and OvenDoor_07, is denied.
|
||
rig.Enforce(true, [Key()], EquipmentGrant(EqB, NodePermissions.ReadOnly));
|
||
|
||
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
||
var denied = await ReadAsync(session, Uns(session, EqA), forward: true, page: 0, ct);
|
||
|
||
denied.Status.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||
denied.HistoryDataIsNull.ShouldBeTrue("a denied folder discloses none of its events");
|
||
gateway.Requested.ShouldBeEmpty("the folder verdict is decided before any source is read (fail closed)");
|
||
|
||
// Positive control: the same session, on the folder it IS granted, gets that folder's Galaxy object.
|
||
var granted = await ReadAsync(session, Uns(session, EqB), forward: true, page: 0, ct);
|
||
granted.Status.ShouldBe(StatusCodes.Good);
|
||
granted.Rows.Select(r => (r.Id, r.SourceNode)).ShouldBe(new (string, string?)[] { ("l-set", Jam), ("l-clear", Jam) });
|
||
gateway.Requested.ShouldBe(new[] { EqB, "Line3_Conveyor" });
|
||
}
|
||
|
||
[Fact]
|
||
public async Task A_rebuild_forgets_the_galaxy_sources_until_the_conditions_are_bound_again()
|
||
{
|
||
var ct = TestContext.Current.CancellationToken;
|
||
await using var rig = await StartAsync("GalaxyEqHistoryRebuild", encrypted: false, ct);
|
||
var gateway = new AvevaShapedGateway();
|
||
rig.Nm.HistorianDataSource = new GatewayHistorianDataSource(gateway, NullLogger<GatewayHistorianDataSource>.Instance);
|
||
Materialise(rig);
|
||
rig.Enforce(false, [Key()]);
|
||
|
||
// The redeploy re-creates the same conditions and wiring, but (here) binds no Galaxy reference: the folder is back
|
||
// to its own source alone — a stale binding would keep reading the old objects.
|
||
rig.Sink.RebuildAddressSpace();
|
||
Materialise(rig, bind: false);
|
||
|
||
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
||
var read = await ReadAsync(session, Uns(session, EqA), forward: true, page: 0, ct);
|
||
|
||
read.Status.ShouldBe(StatusCodes.GoodNoData);
|
||
gateway.Requested.ShouldBe(new[] { EqA });
|
||
}
|
||
|
||
/// <summary>Materialises the topology the applier builds from the Galaxy deployment (see the class remarks).</summary>
|
||
private static void Materialise(ServerEventRig rig, bool bind = true)
|
||
{
|
||
rig.Sink.EnsureFolder(OvenFolder, parentNodeId: null, displayName: "Oven7", realm: AddressSpaceRealm.Raw);
|
||
rig.Sink.EnsureFolder(LineFolder, parentNodeId: null, displayName: "Line3", realm: AddressSpaceRealm.Raw);
|
||
rig.EnsureEquipment();
|
||
foreach (var (id, folder, name, reference, equipment) in new[]
|
||
{
|
||
(TempHi, OvenFolder, "TempHi", "Oven_007.TempHiAlarm", EqA),
|
||
(DoorOpen, OvenFolder, "DoorOpen", "OvenDoor_07.DoorAlarm", EqA),
|
||
(Jam, LineFolder, "Jam", "Line3_Conveyor.JamAlarm", EqB),
|
||
})
|
||
{
|
||
rig.Sink.MaterialiseAlarmCondition(id, folder, name, "OffNormalAlarm", 700, AddressSpaceRealm.Raw, isNative: true);
|
||
if (bind) rig.Sink.BindNativeAlarmHistory(id, AddressSpaceRealm.Raw, reference);
|
||
rig.Sink.WireAlarmNotifiers(id, AddressSpaceRealm.Raw, [equipment], AddressSpaceRealm.Uns);
|
||
}
|
||
}
|
||
|
||
private static NodeId Uns(ISession session, string id) =>
|
||
new(id, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri));
|
||
|
||
private static NodeId Raw(ISession session, string id) =>
|
||
new(id, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri));
|
||
|
||
private static async Task<EventRead> ReadAsync(ISession session, NodeId node, bool forward, uint page, CancellationToken ct)
|
||
{
|
||
var filter = new EventFilter();
|
||
foreach (var leaf in new[] { "EventId", "SourceName", "SourceNode", "Severity", "ConditionName" })
|
||
{
|
||
var type = leaf == "ConditionName" ? ObjectTypeIds.ConditionType : ObjectTypeIds.BaseEventType;
|
||
filter.SelectClauses += new SimpleAttributeOperand(type, new QualifiedName(leaf)) { AttributeId = Attributes.Value };
|
||
}
|
||
|
||
var (from, to) = (Epoch.AddMinutes(-1), Epoch.AddMinutes(1));
|
||
var details = new ReadEventDetails
|
||
{
|
||
StartTime = forward ? from : to, EndTime = forward ? to : from, NumValuesPerNode = page, Filter = filter,
|
||
};
|
||
var rows = new List<Row>();
|
||
ByteString point = default;
|
||
var pages = 0;
|
||
StatusCode first = StatusCodes.Good;
|
||
var historyDataIsNull = true;
|
||
do
|
||
{
|
||
var response = await session.HistoryReadAsync(null, new ExtensionObject(details), TimestampsToReturn.Both, false,
|
||
new HistoryReadValueId[] { new() { NodeId = node, ContinuationPoint = point } }, ct);
|
||
var result = response.Results.ToList().ShouldHaveSingleItem();
|
||
if (pages++ == 0) { first = result.StatusCode; historyDataIsNull = result.HistoryData.IsNull; }
|
||
if (StatusCode.IsBad(result.StatusCode)) break;
|
||
if (!result.HistoryData.IsNull && ExtensionObject.ToEncodeable(result.HistoryData) is HistoryEvent he)
|
||
foreach (var e in he.Events.ToList())
|
||
{
|
||
var f = e.EventFields.ToList();
|
||
rows.Add(new Row(
|
||
System.Text.Encoding.UTF8.GetString(f[0].GetByteString().Span),
|
||
f[1].GetString(),
|
||
f[2].IsNull ? null : f[2].GetNodeId().IdentifierAsString,
|
||
f[3].GetUInt16(),
|
||
f[4].IsNull ? null : f[4].GetString()));
|
||
}
|
||
|
||
point = result.ContinuationPoint;
|
||
} while (!point.IsEmpty && pages < 60);
|
||
|
||
return new EventRead(first, historyDataIsNull, rows, pages);
|
||
}
|
||
|
||
private sealed record AvevaRow(string Id, string Source, string Object, string Type, int Second, int Priority, string? ConditionVariable);
|
||
|
||
private sealed record Row(string Id, string? Source, string? SourceNode, ushort Severity, string? ConditionName);
|
||
|
||
private sealed record EventRead(StatusCode Status, bool HistoryDataIsNull, List<Row> Rows, int Pages);
|
||
|
||
/// <summary>
|
||
/// A deterministic gateway answering ReadEvents with AVEVA-shaped rows — <c>SourceName</c> =
|
||
/// <c>Source_ProcessVariable</c> and lower-case properties as vd03's HistorianGateway returns them — filtered loosely
|
||
/// on <c>source_object</c>, in event-time order with no intra-timestamp order guarantee (here: storage order).
|
||
/// </summary>
|
||
private sealed class AvevaShapedGateway : IHistorianGatewayClient
|
||
{
|
||
private readonly List<string?> _requested = [];
|
||
private readonly List<string> _yielded = [];
|
||
|
||
public IReadOnlyList<string?> Requested { get { lock (_requested) return _requested.ToList(); } }
|
||
|
||
public IReadOnlyList<string> Yielded { get { lock (_yielded) return _yielded.ToList(); } }
|
||
|
||
public async IAsyncEnumerable<HistorianEvent> ReadEventsAsync(
|
||
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, [EnumeratorCancellation] CancellationToken ct)
|
||
{
|
||
lock (_requested) _requested.Add(sourceName);
|
||
await Task.Yield();
|
||
foreach (var s in Stored.OrderBy(s => s.Second))
|
||
{
|
||
var at = Epoch.AddSeconds(s.Second);
|
||
if (at < startUtc || at > endUtc) continue; // the gateway's end is inclusive
|
||
if (sourceName is not null && !s.Object.StartsWith(sourceName, StringComparison.Ordinal)) continue;
|
||
lock (_yielded) _yielded.Add(s.Id);
|
||
var ts = Timestamp.FromDateTime(at);
|
||
var e = new HistorianEvent
|
||
{
|
||
Id = s.Id, SourceName = s.Source, Type = s.Type, Namespace = "Application Server", EventTime = ts, ReceivedTime = ts,
|
||
};
|
||
e.Properties["source_object"] = s.Object;
|
||
e.Properties["source_processvariable"] = s.Source;
|
||
e.Properties["priority"] = s.Priority.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||
// AVEVA's own scale, as vd03 stores it: 0 for an event that is not an alarm, else the priority's 1–4 band.
|
||
// Only the 0 is read (not an alarm → Severity 1); an alarm's value comes from its priority.
|
||
e.Properties["severity"] = s.ConditionVariable is null ? "0"
|
||
: (s.Priority <= 250 ? "1" : s.Priority <= 500 ? "2" : s.Priority <= 750 ? "3" : "4");
|
||
if (s.ConditionVariable is not null)
|
||
{
|
||
e.Properties["source_conditionvariable"] = s.ConditionVariable;
|
||
e.Properties["alarm_id"] = $"guid-{s.ConditionVariable.ToLowerInvariant()}";
|
||
}
|
||
|
||
yield return e;
|
||
}
|
||
}
|
||
|
||
public Task<bool> TagExistsAsync(string tag, CancellationToken ct) => throw new NotSupportedException();
|
||
public IAsyncEnumerable<HistorianSample> ReadRawAsync(string tag, DateTime startUtc, DateTime endUtc, int maxValues, CancellationToken ct) =>
|
||
throw new NotSupportedException();
|
||
public IAsyncEnumerable<HistorianAggregateSample> ReadAggregateAsync(string tag, DateTime startUtc, DateTime endUtc,
|
||
RetrievalMode mode, TimeSpan interval, CancellationToken ct) => throw new NotSupportedException();
|
||
public Task<IReadOnlyList<HistorianSample>> ReadAtTimeAsync(string tag, IReadOnlyList<DateTime> timestampsUtc, CancellationToken ct) =>
|
||
throw new NotSupportedException();
|
||
public Task<WriteAck> WriteLiveValuesAsync(string tag, IReadOnlyList<HistorianLiveValue> values, CancellationToken ct) =>
|
||
throw new NotSupportedException();
|
||
public Task<WriteAck> SendEventAsync(HistorianEvent evt, CancellationToken ct) => throw new NotSupportedException();
|
||
public Task<TagOperationResults> EnsureTagsAsync(IReadOnlyList<HistorianTagDefinition> definitions, CancellationToken ct) =>
|
||
throw new NotSupportedException();
|
||
public Task<bool> ProbeAsync(CancellationToken ct) => Task.FromResult(true);
|
||
public Task<ConnectionStatus> GetConnectionStatusAsync(CancellationToken ct) => Task.FromResult(new ConnectionStatus());
|
||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||
}
|
||
}
|