diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor index b156576d..fedd735e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor @@ -136,6 +136,27 @@ + @* Native-alarm options: shown only when the TagConfig carries an `alarm` object (the tag + is a Part 9 condition). The "Historize to AVEVA" toggle edits the alarm.historizeToAveva + opt-out (bool?, unchecked-via-clear ⇒ absent ⇒ historize default-on at the server gate; + explicit false suppresses the durable AVEVA write — same posture as scripted alarms). *@ + @if (HasNativeAlarm) + { +
+ +
+ + +
+
+ When unchecked, this alarm's transitions are NOT written to the AVEVA historian + (the live alerts feed is unaffected). Checked is the default. +
+
+ } + @if (!string.IsNullOrWhiteSpace(_error)) {
@_error
@@ -234,6 +255,27 @@ ["ConfigJsonChanged"] = EventCallback.Factory.Create(this, v => _form.TagConfig = v), }; + // True when the current TagConfig carries an `alarm` object — i.e. the tag is materialised as a Part 9 + // native-alarm condition rather than a value variable. Gates the "Historize to AVEVA" toggle's visibility. + private bool HasNativeAlarm => NativeAlarmModel.FromJson(_form.TagConfig).IsAlarm; + + // The native alarm's HistorizeToAveva intent reflected for the checkbox: absent (null) ⇒ historize + // (default-on at the server gate), so the box is checked for both null and explicit true; only an + // explicit false leaves it unchecked. + private bool AlarmHistorizeToAveva => NativeAlarmModel.FromJson(_form.TagConfig).HistorizeToAveva != false; + + // Toggle the alarm.historizeToAveva opt-out in the raw TagConfig. Checked ⇒ remove the key (null ⇒ + // absent ⇒ historize default-on); unchecked ⇒ write an explicit false (suppress the durable AVEVA row). + // Unknown keys at the root + inside `alarm` are preserved across the edit (NativeAlarmModel round-trip). + private void OnAlarmHistorizeChanged(ChangeEventArgs e) + { + var model = NativeAlarmModel.FromJson(_form.TagConfig); + if (!model.IsAlarm) { return; } + var isChecked = e.Value is bool b && b; + model.HistorizeToAveva = isChecked ? null : false; + _form.TagConfig = model.ToJson(); + } + protected override void OnParametersSet() { // Rebuild the working form whenever the host (re)opens the modal for a fresh target. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/NativeAlarmModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/NativeAlarmModel.cs new file mode 100644 index 00000000..6a58802f --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/NativeAlarmModel.cs @@ -0,0 +1,102 @@ +using System.Text.Json.Nodes; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; + +/// +/// Typed working model for the optional native-alarm alarm sub-object inside a tag's +/// TagConfig JSON. A tag whose TagConfig carries an alarm object is materialised +/// as an OPC UA Part 9 condition (rather than a value variable); the fields here mirror what the +/// server's Phase7Composer.ExtractTagAlarm / DeploymentArtifact.ExtractTagAlarm parse. +/// +/// +/// is the per-condition opt-out of the DURABLE AVEVA historian write +/// (bool?; absent ⇒ null ⇒ historize). The server's HistorianAdapterActor gates +/// the sink write on historizeToAveva is not false, so null and true both +/// historize and only an explicit false suppresses the durable row — the live /alerts +/// fan-out is unaffected. This mirrors the scripted-alarm HistorizeToAveva opt-out posture. +/// +/// +/// +/// / operate over the WHOLE TagConfig JSON (root + +/// nested alarm), preserving every unrecognised key at both levels so a load→save can't drop a +/// field this editor doesn't expose. +/// +/// +public sealed class NativeAlarmModel +{ + /// True iff the TagConfig carried an alarm object (the tag is an alarm condition). + public bool IsAlarm { get; set; } + + /// OPC UA Part 9 condition subtype (OffNormalAlarm/DiscreteAlarm/LimitAlarm/AlarmCondition). + public string AlarmType { get; set; } = "AlarmCondition"; + + /// 1..1000 OPC UA severity. + public int Severity { get; set; } = 500; + + /// Per-condition opt-out of the durable AVEVA historian write. null ⇒ absent ⇒ + /// historize (default-on at the server gate); true ⇒ historize; false ⇒ suppress the + /// durable row (live alerts fan-out unaffected). + public bool? HistorizeToAveva { get; set; } + + private JsonObject _root = new(); + + /// Loads a model from a TagConfig JSON string. When no alarm object is present the + /// model is flagged non-alarm and the alarm fields keep their defaults. Retains every original key + /// at the root and inside alarm so a load→save preserves fields this editor doesn't expose. + public static NativeAlarmModel FromJson(string? json) + { + var root = TagConfigJson.ParseOrNew(json); + var model = new NativeAlarmModel { _root = root }; + + if (root.TryGetPropertyValue("alarm", out var aNode) && aNode is JsonObject alarm) + { + model.IsAlarm = true; + model.AlarmType = TagConfigJson.GetString(alarm, "alarmType") is { } t && t.Length > 0 + ? t : "AlarmCondition"; + model.Severity = TagConfigJson.GetInt(alarm, "severity", 500); + model.HistorizeToAveva = ReadNullableBool(alarm, "historizeToAveva"); + } + + return model; + } + + /// Serialises this model back to a TagConfig JSON string. When is set, + /// writes the alarm fields over the preserved alarm sub-object (creating it if absent); a + /// null REMOVES the key so the absent ⇒ historize default holds. + public string ToJson() + { + if (IsAlarm) + { + var alarm = _root.TryGetPropertyValue("alarm", out var n) && n is JsonObject existing + ? existing + : new JsonObject(); + + TagConfigJson.Set(alarm, "alarmType", AlarmType); + TagConfigJson.Set(alarm, "severity", Severity); + // null REMOVES the key (absent ⇒ historize default-on at the server gate); a concrete + // true/false is written through. JsonValue.Create(bool) yields a JSON boolean literal. + if (HistorizeToAveva is { } h) + { + alarm["historizeToAveva"] = JsonValue.Create(h); + } + else + { + alarm.Remove("historizeToAveva"); + } + + // Re-attach (no-op when it was already the same node; required when we created a fresh one). + _root["alarm"] = alarm; + } + + return TagConfigJson.Serialize(_root); + } + + /// Validation hook; returns an error message or null when the model is valid. + public string? Validate() => null; + + /// Reads a nullable bool: null when absent/null/non-bool; otherwise the bool value. + private static bool? ReadNullableBool(JsonObject o, string name) + => o.TryGetPropertyValue(name, out var n) && n is JsonValue v && v.TryGetValue(out var b) + ? b + : null; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs index f6933f00..4d683672 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs @@ -99,8 +99,13 @@ public sealed record EquipmentTagPlan( /// Native-alarm intent parsed from an equipment tag's TagConfig.alarm object. Null ⇒ /// the tag is a plain value variable. is an OPC UA Part 9 subtype string -/// (OffNormalAlarm/DiscreteAlarm/LimitAlarm/AlarmCondition); is the 1..1000 scale. -public sealed record EquipmentTagAlarmInfo(string AlarmType, int Severity); +/// (OffNormalAlarm/DiscreteAlarm/LimitAlarm/AlarmCondition); is the 1..1000 scale. +/// is the per-condition opt-out of the durable AVEVA historian write +/// (bool?; absent ⇒ null ⇒ historize). It is threaded onto each native +/// AlarmTransitionEvent so the runtime's HistorianAdapterActor gate +/// (historizeToAveva is not false) suppresses the durable row only on an explicit false — +/// the same posture as the scripted-alarm opt-out; the live /alerts fan-out is unaffected. +public sealed record EquipmentTagAlarmInfo(string AlarmType, int Severity, bool? HistorizeToAveva = null); /// /// One Equipment-namespace VirtualTag from a row (joined to its @@ -493,7 +498,13 @@ public static class Phase7Composer ? (tEl.GetString() ?? "AlarmCondition") : "AlarmCondition"; var sev = a.TryGetProperty("severity", out var sEl) && sEl.ValueKind == JsonValueKind.Number && sEl.TryGetInt32(out var sv) ? sv : 500; - return new EquipmentTagAlarmInfo(type, sev); + // historizeToAveva (bool?, absent ⇒ null ⇒ historize): only an explicit false suppresses the + // durable AVEVA write at the HistorianAdapterActor gate; a non-bool node ⇒ null (default-on). + bool? historize = a.TryGetProperty("historizeToAveva", out var hEl) + && hEl.ValueKind is JsonValueKind.True or JsonValueKind.False + ? hEl.GetBoolean() + : null; + return new EquipmentTagAlarmInfo(type, sev, historize); } catch (JsonException) { return null; } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs index 5e5bd575..6e91f144 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs @@ -679,7 +679,13 @@ public static class DeploymentArtifact ? (tEl.GetString() ?? "AlarmCondition") : "AlarmCondition"; var sev = a.TryGetProperty("severity", out var sEl) && sEl.ValueKind == JsonValueKind.Number && sEl.TryGetInt32(out var sv) ? sv : 500; - return new EquipmentTagAlarmInfo(type, sev); + // historizeToAveva (bool?, absent ⇒ null ⇒ historize): byte-parity with + // Phase7Composer.ExtractTagAlarm — only an explicit false suppresses the durable AVEVA write. + bool? historize = a.TryGetProperty("historizeToAveva", out var hEl) + && hEl.ValueKind is JsonValueKind.True or JsonValueKind.False + ? hEl.GetBoolean() + : null; + return new EquipmentTagAlarmInfo(type, sev, historize); } catch (JsonException) { return null; } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs index af23653e..9a4d8701 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -139,9 +139,13 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers private readonly Dictionary _driverRefByAlarmNodeId = new(StringComparer.Ordinal); - /// Condition NodeId → (EquipmentId, tag Name, OPC UA alarm type) for building the - /// AlarmTransitionEvent fan-out. Built in the same PushDesiredSubscriptions alarm branch. - private readonly Dictionary _alarmMetaByNodeId = + /// Condition NodeId → (EquipmentId, tag Name, OPC UA alarm type, HistorizeToAveva) for building + /// the AlarmTransitionEvent fan-out. Built in the same PushDesiredSubscriptions alarm branch. + /// HistorizeToAveva (bool?, null ⇒ historize) is the native per-condition opt-out parsed from + /// TagConfig.alarm.historizeToAveva; it is threaded onto the transition so the + /// HistorianAdapterActor's is not false gate suppresses the durable AVEVA row only on an + /// explicit false (mirroring the scripted-alarm opt-out). + private readonly Dictionary _alarmMetaByNodeId = new(StringComparer.Ordinal); /// Derives a full Part 9 condition snapshot from each native alarm transition delta, @@ -589,7 +593,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers if (_localRole is RedundancyRole.Secondary or RedundancyRole.Detached) continue; var meta = _alarmMetaByNodeId.TryGetValue(nodeId, out var m) - ? m : (EquipmentId: nodeId, Name: nodeId, AlarmType: "AlarmCondition"); + ? m : (EquipmentId: nodeId, Name: nodeId, AlarmType: "AlarmCondition", HistorizeToAveva: (bool?)null); _mediator.Tell(new Publish(ScriptedAlarmHostActor.AlertsTopic, new AlarmTransitionEvent( AlarmId: nodeId, EquipmentPath: meta.EquipmentId, @@ -605,9 +609,11 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers TimestampUtc: msg.Args.SourceTimestampUtc, AlarmTypeName: meta.AlarmType, Comment: msg.Args.OperatorComment, - // Native alarms always historize (no per-condition opt-out surface yet — that is a - // scripted-alarm plan flag); pass a concrete true so the historian's null-default isn't relied on. - HistorizeToAveva: true))); + // Per-condition opt-out parsed from TagConfig.alarm.historizeToAveva (bool?, null ⇒ absent ⇒ + // historize). The HistorianAdapterActor gate (historizeToAveva is not false) historizes null + + // true and suppresses the durable AVEVA row only on an explicit false — the same posture as the + // scripted-alarm opt-out. null here rides through unchanged (the gate treats it as default-on). + HistorizeToAveva: meta.HistorizeToAveva))); } } @@ -1020,7 +1026,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers // Capture the per-condition metadata the alerts fan-out (ForwardNativeAlarm) needs to build // the AlarmTransitionEvent: the equipment path, the operator-visible alarm name, and the // OPC UA Part 9 subtype. Keyed by the condition NodeId (the projection's own key). - _alarmMetaByNodeId[nodeId] = (t.EquipmentId, t.Name, t.Alarm.AlarmType); + _alarmMetaByNodeId[nodeId] = (t.EquipmentId, t.Name, t.Alarm.AlarmType, t.Alarm.HistorizeToAveva); continue; } if (!_nodeIdByDriverRef.TryGetValue(key, out var set)) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/NativeAlarmHistorizeModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/NativeAlarmHistorizeModelTests.cs new file mode 100644 index 00000000..bdc925fc --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/NativeAlarmHistorizeModelTests.cs @@ -0,0 +1,122 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// Round-trip tests for the native-alarm sub-model's historizeToAveva opt-out +/// (the TagConfig.alarm.historizeToAveva bool?). Absent ⇒ null ⇒ the server's +/// HistorianAdapterActor "is not false" gate still historizes (default-on); explicit +/// false suppresses the durable AVEVA write. Mirrors the scripted-alarm opt-out posture. +/// +public sealed class NativeAlarmHistorizeModelTests +{ + [Fact] + public void FromJson_no_alarm_object_means_not_an_alarm_tag() + { + var m = NativeAlarmModel.FromJson("""{"FullName":"Temp.HiHi"}"""); + + m.IsAlarm.ShouldBeFalse(); + m.HistorizeToAveva.ShouldBeNull(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("{}")] + public void FromJson_returns_non_alarm_for_empty_input(string? json) + { + var m = NativeAlarmModel.FromJson(json); + + m.IsAlarm.ShouldBeFalse(); + m.HistorizeToAveva.ShouldBeNull(); + } + + [Fact] + public void FromJson_alarm_without_historizeToAveva_is_null_default_on() + { + var m = NativeAlarmModel.FromJson( + """{"FullName":"Temp.HiHi","alarm":{"alarmType":"OffNormalAlarm","severity":700}}"""); + + m.IsAlarm.ShouldBeTrue(); + m.AlarmType.ShouldBe("OffNormalAlarm"); + m.Severity.ShouldBe(700); + // Absent ⇒ null ⇒ historize (default-on at the gate). + m.HistorizeToAveva.ShouldBeNull(); + } + + [Fact] + public void FromJson_reads_historizeToAveva_true() + { + var m = NativeAlarmModel.FromJson( + """{"alarm":{"alarmType":"LimitAlarm","severity":500,"historizeToAveva":true}}"""); + + m.HistorizeToAveva.ShouldBe(true); + } + + [Fact] + public void FromJson_reads_historizeToAveva_false_opt_out() + { + var m = NativeAlarmModel.FromJson( + """{"alarm":{"alarmType":"LimitAlarm","severity":500,"historizeToAveva":false}}"""); + + m.HistorizeToAveva.ShouldBe(false); + } + + [Fact] + public void Round_trip_true_persists_true() + { + var m = NativeAlarmModel.FromJson( + """{"alarm":{"alarmType":"LimitAlarm","severity":500}}"""); + m.HistorizeToAveva = true; + + var round = NativeAlarmModel.FromJson(m.ToJson()); + + round.HistorizeToAveva.ShouldBe(true); + round.IsAlarm.ShouldBeTrue(); + } + + [Fact] + public void Round_trip_false_persists_false() + { + var m = NativeAlarmModel.FromJson( + """{"alarm":{"alarmType":"LimitAlarm","severity":500}}"""); + m.HistorizeToAveva = false; + + var round = NativeAlarmModel.FromJson(m.ToJson()); + + round.HistorizeToAveva.ShouldBe(false); + } + + [Fact] + public void Round_trip_null_omits_the_key_default_on() + { + var m = NativeAlarmModel.FromJson( + """{"alarm":{"alarmType":"LimitAlarm","severity":500,"historizeToAveva":true}}"""); + m.HistorizeToAveva = null; + + var json = m.ToJson(); + json.ShouldNotContain("historizeToAveva"); + + NativeAlarmModel.FromJson(json).HistorizeToAveva.ShouldBeNull(); + } + + [Fact] + public void ToJson_preserves_unknown_keys_at_root_and_in_alarm() + { + var m = NativeAlarmModel.FromJson( + """{"FullName":"Temp.HiHi","alarm":{"alarmType":"OffNormalAlarm","severity":700,"customAlarmKey":"keep-me"},"customRootKey":42}"""); + m.HistorizeToAveva = false; + + var json = m.ToJson(); + + json.ShouldContain("FullName"); + json.ShouldContain("Temp.HiHi"); + json.ShouldContain("customRootKey"); + json.ShouldContain("customAlarmKey"); + json.ShouldContain("keep-me"); + json.ShouldContain("historizeToAveva"); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagAlarmTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagAlarmTests.cs index e031f6b3..47a96926 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagAlarmTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagAlarmTests.cs @@ -19,4 +19,19 @@ public class ExtractTagAlarmTests info!.AlarmType.ShouldBe(type); info.Severity.ShouldBe(sev); } + + /// historizeToAveva (bool?, absent ⇒ null ⇒ historize): an explicit true/false parses + /// through; a missing or non-bool node yields null (the HistorianAdapterActor gate then treats it as + /// default-on). Mirrors the scripted-alarm opt-out posture. + [Theory] + [InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500}}", null)] + [InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":true}}", true)] + [InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":false}}", false)] + [InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":\"oops\"}}", null)] + public void ExtractTagAlarm_parses_historizeToAveva(string cfg, bool? expected) + { + var info = Phase7Composer.ExtractTagAlarm(cfg); + info.ShouldNotBeNull(); + info!.HistorizeToAveva.ShouldBe(expected); + } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs index 884fa318..6f38c06d 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs @@ -146,10 +146,78 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase evt.Severity.ShouldBe(700); // AlarmSeverity.High → projector 700 evt.Message.ShouldBe("temperature high"); evt.User.ShouldBe(string.Empty); // no operator comment ⇒ device-origin (empty user) - evt.HistorizeToAveva.ShouldBe(true); + // This tag's TagConfig.alarm carries no historizeToAveva key ⇒ null ⇒ the HistorianAdapterActor + // gate (historizeToAveva is not false) still historizes (default-on). Only an explicit false + // suppresses the durable AVEVA row — see Native_alarm_historizeToAveva_false_threads_through. + evt.HistorizeToAveva.ShouldBeNull(); alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); // exactly one } + /// Native-alarm HistorizeToAveva opt-out (Task 3): a tag whose TagConfig.alarm carries + /// historizeToAveva: false publishes its with + /// HistorizeToAveva == false, so the runtime's HistorianAdapterActor gate + /// (historizeToAveva is not false) suppresses the durable AVEVA write — the same opt-out the + /// scripted-alarm plan flag drives. The live /alerts fan-out is unaffected (the transition still + /// publishes; only the durable row is gated downstream). + [Fact] + public void Native_alarm_historizeToAveva_false_threads_through() + { + var db = NewInMemoryDbFactory(); + var deploymentId = SeedDeploymentWithAlarmTag(db, RevA, + Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi", + historizeToAveva: false); + + var alerts = CreateTestProbe(); + SubscribeToAlerts(alerts); + + var (actor, publish) = SpawnHostAndApply(db, deploymentId); + + actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs( + new StubAlarmHandle(), + SourceNodeId: "Temp", + ConditionId: "Temp.HiHi", + AlarmType: "OffNormalAlarm", + Message: "temperature high", + Severity: AlarmSeverity.High, + SourceTimestampUtc: Ts, + Kind: AlarmTransitionKind.Raise))); + + var evt = alerts.ExpectMsg(TimeSpan.FromSeconds(5)); + evt.AlarmId.ShouldBe("eq-1/temp_hi"); + // The explicit opt-out rides onto the transition ⇒ the historian gate suppresses the durable row. + evt.HistorizeToAveva.ShouldBe(false); + } + + /// Native-alarm HistorizeToAveva opt-IN (Task 3): an explicit historizeToAveva: true + /// rides through as true (distinct from the absent ⇒ null default-on case) so an operator who + /// deliberately opts in is recorded as such on the transition. + [Fact] + public void Native_alarm_historizeToAveva_true_threads_through() + { + var db = NewInMemoryDbFactory(); + var deploymentId = SeedDeploymentWithAlarmTag(db, RevA, + Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi", + historizeToAveva: true); + + var alerts = CreateTestProbe(); + SubscribeToAlerts(alerts); + + var (actor, publish) = SpawnHostAndApply(db, deploymentId); + + actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs( + new StubAlarmHandle(), + SourceNodeId: "Temp", + ConditionId: "Temp.HiHi", + AlarmType: "OffNormalAlarm", + Message: "temperature high", + Severity: AlarmSeverity.High, + SourceTimestampUtc: Ts, + Kind: AlarmTransitionKind.Raise))); + + var evt = alerts.ExpectMsg(TimeSpan.FromSeconds(5)); + evt.HistorizeToAveva.ShouldBe(true); + } + /// Secondary suppression (Phase B WS-5): when the cached local role is Secondary the host /// MUST still write the local OPC UA condition node (ungated — keeps the standby's address space warm /// for failover) but MUST NOT publish the cluster-wide alerts transition (the Primary publishes @@ -292,8 +360,14 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase /// private static DeploymentId SeedDeploymentWithAlarmTag( IDbContextFactory db, RevisionHash rev, - string Equip, string Driver, string FullName, string? Folder, string Name) + string Equip, string Driver, string FullName, string? Folder, string Name, + bool? historizeToAveva = null) { + // historizeToAveva absent (null) ⇒ omit the key entirely so the absent ⇒ historize default path is + // exercised; a concrete true/false writes the bool into the alarm object so the native path threads it. + object alarm = historizeToAveva is { } h + ? new { alarmType = "OffNormalAlarm", severity = 700, historizeToAveva = h } + : new { alarmType = "OffNormalAlarm", severity = 700 }; var artifact = JsonSerializer.SerializeToUtf8Bytes(new { Namespaces = new[] @@ -317,7 +391,7 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase TagConfig = JsonSerializer.Serialize(new { FullName, - alarm = new { alarmType = "OffNormalAlarm", severity = 700 }, + alarm, }), }, },