From 069a5f3165a1389a3a0241c8739c5e58caec726b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 16:55:05 -0400 Subject: [PATCH] feat(adminui): Galaxy picker pre-fills native-alarm fields from IsAlarm --- .../Browsing/BrowseNode.cs | 7 +++- .../GalaxyBrowseSession.cs | 3 +- .../Pickers/GalaxyAddressPickerBody.razor | 29 ++++++++++++- .../Components/Shared/Uns/TagModal.razor | 18 +++++++- .../Uns/TagEditors/NativeAlarmModel.cs | 35 ++++++++++++++++ .../Uns/NativeAlarmSeedTests.cs | 42 +++++++++++++++++++ 6 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/NativeAlarmSeedTests.cs diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/BrowseNode.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/BrowseNode.cs index 33dbacac..c7d05e6e 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/BrowseNode.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/BrowseNode.cs @@ -25,8 +25,13 @@ public enum BrowseNodeKind /// Metadata for an attribute of a Galaxy object (or the equivalent /// per-driver concept). Surfaced in the picker's attribute side-panel. +/// True when this attribute is itself an alarm condition (Galaxy: carries an +/// AlarmExtension primitive). The picker pre-fills a default native-alarm alarm object +/// into the TagConfig when an alarm attribute is selected. Defaults to false so non-alarm-aware +/// drivers (e.g. the OPC UA client browser) aren't forced to flow a flag they don't produce. public sealed record AttributeInfo( string Name, string DriverDataType, bool IsArray, - string SecurityClass); + string SecurityClass, + bool IsAlarm = false); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyBrowseSession.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyBrowseSession.cs index 431c867c..bdbbeed0 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyBrowseSession.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyBrowseSession.cs @@ -114,7 +114,8 @@ internal sealed class GalaxyBrowseSession : IBrowseSession Name: attr.AttributeName, DriverDataType: driverType, IsArray: attr.IsArray, - SecurityClass: MapSecurityClass(attr.SecurityClassification))); + SecurityClass: MapSecurityClass(attr.SecurityClassification), + IsAlarm: attr.IsAlarm)); } return result; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/GalaxyAddressPickerBody.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/GalaxyAddressPickerBody.razor index 9238f11a..5aa73aeb 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/GalaxyAddressPickerBody.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/GalaxyAddressPickerBody.razor @@ -15,13 +15,13 @@
+ @bind="_tagName" @bind:after="OnManualChangedAsync" />
Globally unique system (tag) name.
+ @bind="_attributeName" @bind:after="OnManualChangedAsync" />
MXAccess attribute name.
@@ -100,6 +100,13 @@ [Parameter] public EventCallback CurrentAddressChanged { get; set; } [Parameter, EditorRequired] public Func GetConfigJson { get; set; } = () => "{}"; + /// True when the currently-selected attribute is itself an alarm condition + /// (DriverAttributeInfo.IsAlarm). The host (TagModal) pre-fills a default native-alarm + /// object into the TagConfig on commit. Only a side-panel attribute click sets this; manual + /// tag/attribute typing or an object re-selection clears it (we can't infer alarm-ness by name). + [Parameter] public bool SelectedIsAlarm { get; set; } + [Parameter] public EventCallback SelectedIsAlarmChanged { get; set; } + private string _tagName = ""; private string _attributeName = ""; private string _built = ""; @@ -146,6 +153,7 @@ { _tagName = node.NodeId; _attributeName = ""; + await SetSelectedIsAlarmAsync(false); _attrs = null; _attrsLoading = true; _attrsError = null; @@ -168,6 +176,7 @@ private async Task SelectAttributeAsync(AttributeInfo a) { _attributeName = a.Name; + await SetSelectedIsAlarmAsync(a.IsAlarm); await OnChangedAsync(); } @@ -177,6 +186,22 @@ await CurrentAddressChanged.InvokeAsync(_built); } + /// Manual tag/attribute typing: we can't infer alarm-ness from a hand-typed name, so + /// clear the pre-fill flag before publishing the rebuilt address. + private async Task OnManualChangedAsync() + { + await SetSelectedIsAlarmAsync(false); + await OnChangedAsync(); + } + + /// Publishes the selected attribute's alarm-ness to the host, only when it changed. + private async Task SetSelectedIsAlarmAsync(bool isAlarm) + { + if (SelectedIsAlarm == isAlarm) { return; } + SelectedIsAlarm = isAlarm; + await SelectedIsAlarmChanged.InvokeAsync(isAlarm); + } + private string Build() { if (string.IsNullOrWhiteSpace(_tagName)) 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 b791f75c..27e2bac8 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 @@ -118,6 +118,7 @@ OnPickAddress="@OnGalaxyAddressPicked"> } @@ -244,6 +245,11 @@ private bool _showGalaxyPicker; private string _galaxyAddress = ""; + // True when the attribute most-recently selected in the Galaxy picker is itself an alarm + // (DriverAttributeInfo.IsAlarm). On commit, this pre-fills a default native-alarm object into the + // TagConfig (when none is present) so the operator can author the alarm in one pass. + private bool _galaxyPickedIsAlarm; + // Driver-agnostic server-side HistoryRead intent (root `isHistorized` / `historianTagname`), reflected // for the "Historize this tag" controls. Re-read from _form.TagConfig whenever the modal (re)opens or // the driver changes; the change handlers merge it back onto _form.TagConfig via TagHistorizeConfig. @@ -276,6 +282,7 @@ _form.TagConfig = "{}"; // The Galaxy reference belongs to the previous driver; clear the picker's working address too. _galaxyAddress = ""; + _galaxyPickedIsAlarm = false; // The reset TagConfig carries no history intent — reflect that in the historize controls. _historizeState = TagHistorizeConfig.Read(_form.TagConfig); } @@ -283,10 +290,18 @@ // The operator picked a Galaxy reference (tag_name.AttributeName); store it as the canonical // {"FullName":"..."} TagConfig the Galaxy driver resolves to an MXAccess reference. Default // (PascalCase) serialization yields the "FullName" key the driver/walker reads. + // + // When the picked attribute is itself an alarm (DriverAttributeInfo.IsAlarm), pre-seed a default + // native-alarm `alarm` object so the tag materialises as a Part 9 condition and Task 3's + // "Historize to AVEVA" toggle auto-appears — sparing the operator hand-written JSON. NativeAlarmModel + // .SeedDefaultAlarm only seeds when absent (never overwrites an authored alarm) and preserves FullName. private void OnGalaxyAddressPicked(string address) { _galaxyAddress = address; - _form.TagConfig = JsonSerializer.Serialize(new { FullName = address }); + var config = JsonSerializer.Serialize(new { FullName = address }); + _form.TagConfig = _galaxyPickedIsAlarm + ? NativeAlarmModel.SeedDefaultAlarm(config) + : config; } private IDictionary BuildEditorParameters() => new Dictionary @@ -374,6 +389,7 @@ } _error = null; _showGalaxyPicker = false; + _galaxyPickedIsAlarm = false; // Seed the picker's working address from any existing {"FullName":"..."} so it opens pre-populated. _galaxyAddress = ReadFullName(_form.TagConfig); // Seed the historize controls from any existing root isHistorized/historianTagname keys. 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 index 6a58802f..0ab3a68e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/NativeAlarmModel.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/NativeAlarmModel.cs @@ -94,6 +94,41 @@ public sealed class NativeAlarmModel /// Validation hook; returns an error message or null when the model is valid. public string? Validate() => null; + /// Default alarmType seeded when an alarm-bearing attribute is picked but the + /// TagConfig has no alarm object yet. Galaxy's AlarmExtension is a boolean + /// off-normal condition, so OffNormalAlarm is the natural Part 9 subtype. + public const string DefaultSeedAlarmType = "OffNormalAlarm"; + + /// Default OPC UA severity (1..1000) seeded for a freshly pre-filled native alarm. + public const int DefaultSeedSeverity = 700; + + /// + /// Pre-seeds a default native-alarm alarm object into when the + /// selected driver attribute is itself an alarm and the TagConfig carries no alarm object + /// yet. This is the Galaxy-picker convenience: picking an IsAlarm attribute materialises a + /// ready-to-author Part 9 condition ({"alarmType":"OffNormalAlarm","severity":700}) so the + /// operator doesn't hand-write the JSON. Every existing key (root + a pre-existing alarm) is + /// preserved: when an alarm object already exists this returns the input unchanged (never + /// overwrites an authored alarm). Returns the (possibly unchanged) TagConfig JSON string. + /// + public static string SeedDefaultAlarm(string? json) + { + var root = TagConfigJson.ParseOrNew(json); + + // Never overwrite an existing alarm object (only seed when absent). + if (root.TryGetPropertyValue("alarm", out var existing) && existing is JsonObject) + { + return TagConfigJson.Serialize(root); + } + + var alarm = new JsonObject(); + TagConfigJson.Set(alarm, "alarmType", DefaultSeedAlarmType); + TagConfigJson.Set(alarm, "severity", DefaultSeedSeverity); + root["alarm"] = alarm; + + return TagConfigJson.Serialize(root); + } + /// 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) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/NativeAlarmSeedTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/NativeAlarmSeedTests.cs new file mode 100644 index 00000000..d910f8d2 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/NativeAlarmSeedTests.cs @@ -0,0 +1,42 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// Tests for — the Galaxy-picker convenience that +/// pre-fills a default native-alarm alarm object when an IsAlarm attribute is selected. +/// Seeds only when absent (never overwrites an authored alarm) and preserves every other key. +/// +public sealed class NativeAlarmSeedTests +{ + [Fact] + public void Seeds_default_alarm_when_absent() + { + var seeded = NativeAlarmModel.SeedDefaultAlarm("""{"FullName":"Pump_001.HiHi"}"""); + + var m = NativeAlarmModel.FromJson(seeded); + m.IsAlarm.ShouldBeTrue(); + m.AlarmType.ShouldBe(NativeAlarmModel.DefaultSeedAlarmType); // OffNormalAlarm + m.Severity.ShouldBe(NativeAlarmModel.DefaultSeedSeverity); // 700 + // FullName is preserved alongside the freshly-seeded alarm. + seeded.ShouldContain("FullName"); + seeded.ShouldContain("Pump_001.HiHi"); + } + + [Fact] + public void Does_not_overwrite_an_existing_alarm_object() + { + const string authored = + """{"FullName":"Pump_001.HiHi","alarm":{"alarmType":"LimitAlarm","severity":250,"historizeToAveva":false}}"""; + + var result = NativeAlarmModel.SeedDefaultAlarm(authored); + + var m = NativeAlarmModel.FromJson(result); + // The authored alarm is left untouched — not replaced with the default seed. + m.AlarmType.ShouldBe("LimitAlarm"); + m.Severity.ShouldBe(250); + m.HistorizeToAveva.ShouldBe(false); + } +}