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 @@
@@ -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);
+ }
+}