diff --git a/docs/Raw.md b/docs/Raw.md
index f49f90d3..098c1fc5 100644
--- a/docs/Raw.md
+++ b/docs/Raw.md
@@ -68,6 +68,16 @@ removed later.
auto-creates nested groups; typed columns win over `TagConfigJson` on conflict (flagged in
the review grid). **Import is all-or-nothing** — any invalid row blocks the whole commit.
Export round-trips the same shape.
+- **Native alarm section (Gitea #642)** — the Edit-tag modal carries a shared `NativeAlarmEditor`
+ below the per-driver editor / JSON textarea: a "Native alarm (Part 9 condition)" switch plus
+ `alarmType`, `severity` (1..1000) and `historizeToAveva` (default/yes/no), writing the
+ driver-agnostic `alarm` object of `TagConfig`. Offered only for `IAlarmSource` drivers (MTConnect,
+ AbCip, FOCAS, OpcUaClient, Galaxy — `NativeAlarmModel.SupportsNativeAlarms`); on any other driver an
+ existing `alarm` object is shown with a "no effect" note and a Remove button, never silently
+ dropped. Switching it off REMOVES the `alarm` key. Validation is advisory only (warnings) — the
+ deploy gate has no `alarm` rule and the server clamps severity, so the editor is never stricter.
+ Every edit re-parses the latest `ConfigJson` (`NativeAlarmModel.Edit`), so it composes with the
+ driver editor in either order. Not yet on the manual-entry grid (use JSON/CSV there).
- **Browse device…** — enabled per the two-tier resolution (bespoke `IDriverBrowser` →
universal `DiscoveryDriverBrowser` when the driver's `ITagDiscovery.SupportsOnlineDiscovery`
is true → grayed out otherwise). Multi-selected leaves become raw `Tag` rows under the
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTagModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTagModal.razor
index 6f1438aa..474922f4 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTagModal.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTagModal.razor
@@ -9,6 +9,7 @@
@using Microsoft.AspNetCore.Components.Forms
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
+@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Uns.TagEditors
@using ZB.MOM.WW.OtOpcUa.Commons.Types
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@inject IRawTreeService Svc
@@ -90,6 +91,9 @@
Schemaless per driver type. Validated server-side at deploy.
}
+
}
@if (!string.IsNullOrWhiteSpace(_error))
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/NativeAlarmEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/NativeAlarmEditor.razor
new file mode 100644
index 00000000..b5e5bb68
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/NativeAlarmEditor.razor
@@ -0,0 +1,104 @@
+@* Shared native-alarm section (Gitea #642): authors the driver-agnostic TagConfig `alarm` object
+ (alarmType / severity / historizeToAveva) for a raw tag. A thin shell over NativeAlarmModel — all rules
+ live there. It sits OUTSIDE the per-driver typed editor (Galaxy has none) and writes the same ConfigJson.
+
+ Order-safety: every change goes through NativeAlarmModel.Edit(ConfigJson, ...), which re-parses the
+ LATEST json, so it can never clobber a key the driver editor just wrote; the driver models in turn
+ preserve the `alarm` key (pinned by NativeAlarmEditorModelTests).
+
+ Plain input/select + @onchange (no InputNumber/EditForm binding — see the AdminUI binding pitfalls). *@
+@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
+
+@if (_m.IsAlarm || _supported)
+{
+
+ @if (!_supported)
+ {
+ @* Never hide an authored alarm silently: show it and offer removal rather than dropping it. *@
+
+ This tag carries an alarm object, but driver type
+ @DriverType does not raise native alarms, so it has no effect.
+
+
Update(m => m.IsAlarm = false))">Remove alarm object
+ }
+ else
+ {
+
+ Update(m => m.IsAlarm = e.Value is true))" />
+ Native alarm (Part 9 condition)
+
+ @if (_m.IsAlarm)
+ {
+
+
+ Alarm type
+ Update(m => m.AlarmType = e.Value?.ToString() ?? "AlarmCondition"))">
+ @foreach (var t in NativeAlarmModel.KnownAlarmTypes) { @t }
+ @if (!NativeAlarmModel.KnownAlarmTypes.Contains(_m.AlarmType))
+ {
+ @_m.AlarmType (custom)
+ }
+
+
+
+ Severity (1..1000)
+ Update(m => m.Severity = int.TryParse(e.Value?.ToString(), out var s) ? s : 500))" />
+
+
+ Historize to AVEVA
+ Update(m => m.HistorizeToAveva = ParseHistorize(e.Value?.ToString())))">
+ Default (yes)
+ Yes
+ No (live alerts only)
+
+
+
+ @foreach (var w in _m.Warnings())
+ {
+
@w
+ }
+ }
+ }
+
+}
+
+@code {
+ /// The tag's raw TagConfig JSON (supports @bind-ConfigJson ).
+ [Parameter] public string? ConfigJson { get; set; }
+ /// Two-way config callback.
+ [Parameter] public EventCallback ConfigJsonChanged { get; set; }
+ /// Driver type of the owning device; gates whether the editor is offered.
+ [Parameter] public string? DriverType { get; set; }
+
+ private NativeAlarmModel _m = new();
+ private bool _supported;
+ private string? _lastConfigJson;
+
+ protected override void OnParametersSet()
+ {
+ _supported = NativeAlarmModel.SupportsNativeAlarms(DriverType);
+ if (ConfigJson == _lastConfigJson) { return; }
+ _lastConfigJson = ConfigJson;
+ _m = NativeAlarmModel.FromJson(ConfigJson);
+ }
+
+ private static string HistorizeValue(bool? v) => v is null ? "default" : v.Value ? "yes" : "no";
+
+ private static bool? ParseHistorize(string? v) => v switch { "yes" => true, "no" => false, _ => null };
+
+ private async Task Update(Action apply)
+ {
+ var json = NativeAlarmModel.Edit(ConfigJson, apply);
+ _lastConfigJson = json;
+ _m = NativeAlarmModel.FromJson(json);
+ await ConfigJsonChanged.InvokeAsync(json);
+ }
+}
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 7ac2e5b4..26517b58 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
@@ -90,14 +90,72 @@ public sealed class NativeAlarmModel
// Re-attach (no-op when it was already the same node; required when we created a fresh one).
_root["alarm"] = alarm;
}
+ else
+ {
+ // Toggled off (#642): the alarm object must go, or a "disabled" tag would still deploy as a condition.
+ _root.Remove("alarm");
+ }
return TagConfigJson.Serialize(_root);
}
/// Validation hook; returns an error message or null when the model is valid.
/// An error message describing the validation failure, or null when the model is valid.
+ /// Deliberately never errors: the deploy gate has no alarm rule and
+ /// TagConfigIntent.ParseAlarm accepts any alarmType string and any severity (clamped 1..1000 at
+ /// publish), so rejecting here would be stricter than the gate. Out-of-range input is advisory —
+ /// see .
public string? Validate() => null;
+ /// Advisory messages for values the server will accept but silently normalise.
+ /// Zero or more warning strings.
+ public IEnumerable Warnings()
+ {
+ if (!IsAlarm) { yield break; }
+ if (Severity is < 1 or > 1000)
+ {
+ yield return $"Severity {Severity} is outside 1..1000; the server clamps it.";
+ }
+ if (!KnownAlarmTypes.Contains(AlarmType))
+ {
+ yield return $"Alarm type '{AlarmType}' is not one of {string.Join("/", KnownAlarmTypes)}; the server treats it as AlarmCondition.";
+ }
+ }
+
+ /// The Part 9 condition subtypes the server distinguishes (OffNormal/Discrete; the rest map to the base condition).
+ public static readonly IReadOnlyList KnownAlarmTypes =
+ ["AlarmCondition", "OffNormalAlarm", "DiscreteAlarm", "LimitAlarm"];
+
+ /// Driver types that implement IAlarmSource (so a TagConfig alarm object is
+ /// meaningful). The alarm object itself is driver-agnostic; this only gates the editor's visibility.
+ private static readonly HashSet AlarmDriverTypes = new(StringComparer.OrdinalIgnoreCase)
+ {
+ Core.Abstractions.DriverTypeNames.MTConnect, Core.Abstractions.DriverTypeNames.AbCip,
+ Core.Abstractions.DriverTypeNames.FOCAS, Core.Abstractions.DriverTypeNames.OpcUaClient,
+ Core.Abstractions.DriverTypeNames.Galaxy,
+ };
+
+ /// True when the driver type can emit native alarms.
+ /// Driver type name.
+ /// Whether the native-alarm editor applies.
+ public static bool SupportsNativeAlarms(string? driverType)
+ => driverType is not null && AlarmDriverTypes.Contains(driverType);
+
+ ///
+ /// Order-safe edit: re-parses the LATEST (so a keystroke in the per-driver
+ /// editor is never overwritten by a stale cached copy), applies , serialises.
+ /// Only the alarm key is touched; every other key is preserved verbatim.
+ ///
+ /// Current TagConfig JSON.
+ /// Edit to apply to the freshly-parsed model.
+ /// The new TagConfig JSON.
+ public static string Edit(string? json, Action mutate)
+ {
+ var m = FromJson(json);
+ mutate(m);
+ return m.ToJson();
+ }
+
/// 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.
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/NativeAlarmDriverSetGuardTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/NativeAlarmDriverSetGuardTests.cs
new file mode 100644
index 00000000..19ceab1a
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/NativeAlarmDriverSetGuardTests.cs
@@ -0,0 +1,53 @@
+using System.Reflection;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
+
+///
+/// Gitea #642 guard: is a hand-maintained set; it must equal
+/// the drivers whose concrete types implement . Discovery is by reflection over the
+/// driver assemblies deployed beside this test; an assembly's driver type is the
+/// constant whose FIELD NAME equals the assembly suffix (Driver.Galaxy -> Galaxy = "GalaxyMxGateway").
+///
+public sealed class NativeAlarmDriverSetGuardTests
+{
+ private static readonly string[] NonDriverSuffixes = [".Contracts", ".Addressing", ".Cli", ".Historian.Gateway"];
+
+ [Fact]
+ public void SupportsNativeAlarms_matches_the_drivers_implementing_IAlarmSource()
+ {
+ var binDir = Path.GetDirectoryName(typeof(NativeAlarmDriverSetGuardTests).Assembly.Location)!;
+ var constants = typeof(DriverTypeNames).GetFields(BindingFlags.Public | BindingFlags.Static)
+ .Where(f => f.IsLiteral && f.FieldType == typeof(string))
+ .ToDictionary(f => f.Name, f => (string)f.GetRawConstantValue()!, StringComparer.OrdinalIgnoreCase);
+
+ const string prefix = "ZB.MOM.WW.OtOpcUa.Driver.";
+ var implementers = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var scanned = 0;
+ foreach (var dll in Directory.GetFiles(binDir, prefix + "*.dll"))
+ {
+ var name = Path.GetFileNameWithoutExtension(dll);
+ if (NonDriverSuffixes.Any(s => name.EndsWith(s, StringComparison.Ordinal))) { continue; }
+ Type[] types;
+ try { types = Assembly.LoadFrom(dll).GetTypes(); }
+ catch (ReflectionTypeLoadException ex) { types = ex.Types.Where(t => t is not null).ToArray()!; }
+ scanned++;
+ if (!types.Any(t => t.IsClass && !t.IsAbstract && typeof(IAlarmSource).IsAssignableFrom(t))) { continue; }
+ var suffix = name[prefix.Length..];
+ constants.TryGetValue(suffix, out var typeName).ShouldBeTrue($"no DriverTypeNames constant named '{suffix}' for {name}");
+ implementers.Add(typeName!);
+ }
+
+ scanned.ShouldBeGreaterThan(5, "driver assemblies were not deployed to the test output; the guard would be vacuous");
+ implementers.Count.ShouldBeGreaterThan(0);
+
+ foreach (var typeName in constants.Values)
+ {
+ NativeAlarmModel.SupportsNativeAlarms(typeName).ShouldBe(implementers.Contains(typeName),
+ $"NativeAlarmModel.AlarmDriverTypes disagrees with IAlarmSource for driver type '{typeName}'");
+ }
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/NativeAlarmEditorModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/NativeAlarmEditorModelTests.cs
new file mode 100644
index 00000000..1e2d3ce0
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/NativeAlarmEditorModelTests.cs
@@ -0,0 +1,106 @@
+using System.Text.Json.Nodes;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
+
+/// Gitea #642 — the shared NativeAlarmEditor's pure-model behaviour: remove-on-off, composition
+/// with the per-driver editors' models, validation parity with the deploy gate, driver-type gating.
+public sealed class NativeAlarmEditorModelTests
+{
+ [Fact]
+ public void ToJson_with_IsAlarm_false_removes_the_alarm_key_and_keeps_the_rest()
+ {
+ var m = NativeAlarmModel.FromJson("""{"FullName":"X.Y","alarm":{"alarmType":"LimitAlarm","severity":300,"extra":1}}""");
+ m.IsAlarm = false;
+
+ var o = JsonNode.Parse(m.ToJson())!.AsObject();
+ o.ContainsKey("alarm").ShouldBeFalse();
+ o["FullName"]!.GetValue().ShouldBe("X.Y");
+ }
+
+ [Fact]
+ public void Toggle_off_then_on_again_starts_from_defaults()
+ {
+ var off = NativeAlarmModel.Edit("""{"alarm":{"severity":300}}""", m => m.IsAlarm = false);
+ var on = NativeAlarmModel.Edit(off, m => m.IsAlarm = true);
+ var back = NativeAlarmModel.FromJson(on);
+ back.IsAlarm.ShouldBeTrue();
+ back.Severity.ShouldBe(500);
+ back.AlarmType.ShouldBe("AlarmCondition");
+ }
+
+ [Fact]
+ public void Edit_preserves_unknown_keys_at_both_levels()
+ {
+ var json = NativeAlarmModel.Edit("""{"FullName":"A","zz":1,"alarm":{"alarmType":"OffNormalAlarm","custom":"k"}}""",
+ m => m.Severity = 900);
+ var o = JsonNode.Parse(json)!.AsObject();
+ o["zz"]!.GetValue().ShouldBe(1);
+ o["alarm"]!["custom"]!.GetValue().ShouldBe("k");
+ o["alarm"]!["severity"]!.GetValue().ShouldBe(900);
+ }
+
+ [Fact]
+ public void Alarm_and_mtconnect_driver_editor_compose_in_either_order()
+ {
+ const string start = """{"fullName":"Xact"}""";
+
+ // alarm first, then driver editor (driver model re-parses the alarm-bearing JSON)
+ var a1 = NativeAlarmModel.Edit(start, m => { m.IsAlarm = true; m.Severity = 800; });
+ var d1 = MTConnectTagConfigModel.FromJson(a1);
+ d1.FullName = "Yact";
+ var r1 = d1.ToJson();
+
+ // driver editor first, then alarm
+ var d2 = MTConnectTagConfigModel.FromJson(start);
+ d2.FullName = "Yact";
+ var r2 = NativeAlarmModel.Edit(d2.ToJson(), m => { m.IsAlarm = true; m.Severity = 800; });
+
+ foreach (var r in new[] { r1, r2 })
+ {
+ var o = JsonNode.Parse(r)!.AsObject();
+ o["fullName"]!.GetValue().ShouldBe("Yact");
+ o["alarm"]!["severity"]!.GetValue().ShouldBe(800);
+ }
+ }
+
+ [Theory]
+ [InlineData("Modbus")]
+ [InlineData("S7")]
+ [InlineData("AbCip")]
+ [InlineData("AbLegacy")]
+ [InlineData("TwinCAT")]
+ [InlineData("FOCAS")]
+ [InlineData("OpcUaClient")]
+ [InlineData("Sql")]
+ [InlineData("Mqtt")]
+ [InlineData("MTConnect")]
+ public void Every_typed_driver_model_preserves_an_alarm_object(string driverType)
+ {
+ const string json = """{"alarm":{"alarmType":"LimitAlarm","severity":123}}""";
+ var round = TagConfigRoundTrip.Through(driverType, json);
+ NativeAlarmModel.FromJson(round).Severity.ShouldBe(123);
+ }
+
+ [Fact]
+ public void Validate_is_not_stricter_than_the_deploy_gate()
+ {
+ // The gate/parser accepts any severity (clamped 1..1000 at publish) and any alarmType string.
+ var m = NativeAlarmModel.FromJson("""{"alarm":{"alarmType":"Whatever","severity":5000}}""");
+ m.Validate().ShouldBeNull();
+ m.Warnings().ShouldNotBeEmpty(); // advisory only
+ }
+
+ [Fact]
+ public void SupportsNativeAlarms_covers_exactly_the_IAlarmSource_drivers()
+ {
+ foreach (var t in new[] { DriverTypeNames.MTConnect, DriverTypeNames.AbCip, DriverTypeNames.FOCAS,
+ DriverTypeNames.OpcUaClient, DriverTypeNames.Galaxy })
+ NativeAlarmModel.SupportsNativeAlarms(t).ShouldBeTrue(t);
+ foreach (var t in new[] { DriverTypeNames.Modbus, DriverTypeNames.S7, DriverTypeNames.Mqtt, null })
+ NativeAlarmModel.SupportsNativeAlarms(t).ShouldBeFalse(t);
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigRoundTrip.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigRoundTrip.cs
new file mode 100644
index 00000000..e13f074a
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigRoundTrip.cs
@@ -0,0 +1,23 @@
+using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
+
+/// Test helper: FromJson→ToJson through the driver's typed TagConfig model.
+internal static class TagConfigRoundTrip
+{
+ public static string Through(string driverType, string json) => driverType switch
+ {
+ DriverTypeNames.Modbus => ModbusTagConfigModel.FromJson(json).ToJson(),
+ DriverTypeNames.S7 => S7TagConfigModel.FromJson(json).ToJson(),
+ DriverTypeNames.AbCip => AbCipTagConfigModel.FromJson(json).ToJson(),
+ DriverTypeNames.AbLegacy => AbLegacyTagConfigModel.FromJson(json).ToJson(),
+ DriverTypeNames.TwinCAT => TwinCATTagConfigModel.FromJson(json).ToJson(),
+ DriverTypeNames.FOCAS => FocasTagConfigModel.FromJson(json).ToJson(),
+ DriverTypeNames.OpcUaClient => OpcUaClientTagConfigModel.FromJson(json).ToJson(),
+ DriverTypeNames.Sql => SqlTagConfigModel.FromJson(json).ToJson(),
+ DriverTypeNames.Mqtt => MqttTagConfigModel.FromJson(json).ToJson(),
+ DriverTypeNames.MTConnect => MTConnectTagConfigModel.FromJson(json).ToJson(),
+ _ => throw new ArgumentOutOfRangeException(nameof(driverType)),
+ };
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj
index 742ab993..69af045a 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj
@@ -40,6 +40,10 @@
+
+
+
+