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. +
+ + } + else + { +
+ + +
+ @if (_m.IsAlarm) + { +
+
+ + +
+
+ + +
+
+ + +
+
+ @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 @@ + + + +