feat(adminui): Galaxy picker pre-fills native-alarm fields from IsAlarm
This commit is contained in:
@@ -25,8 +25,13 @@ public enum BrowseNodeKind
|
||||
|
||||
/// <summary>Metadata for an attribute of a Galaxy object (or the equivalent
|
||||
/// per-driver concept). Surfaced in the picker's attribute side-panel.</summary>
|
||||
/// <param name="IsAlarm">True when this attribute is itself an alarm condition (Galaxy: carries an
|
||||
/// <c>AlarmExtension</c> primitive). The picker pre-fills a default native-alarm <c>alarm</c> 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.</param>
|
||||
public sealed record AttributeInfo(
|
||||
string Name,
|
||||
string DriverDataType,
|
||||
bool IsArray,
|
||||
string SecurityClass);
|
||||
string SecurityClass,
|
||||
bool IsAlarm = false);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+27
-2
@@ -15,13 +15,13 @@
|
||||
<div class="col-md-5">
|
||||
<label class="form-label">Tag name</label>
|
||||
<input type="text" class="form-control form-control-sm mono" placeholder="DelmiaReceiver_001"
|
||||
@bind="_tagName" @bind:after="OnChangedAsync" />
|
||||
@bind="_tagName" @bind:after="OnManualChangedAsync" />
|
||||
<div class="form-text">Globally unique system (tag) name.</div>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label">Attribute name</label>
|
||||
<input type="text" class="form-control form-control-sm mono" placeholder="DownloadPath"
|
||||
@bind="_attributeName" @bind:after="OnChangedAsync" />
|
||||
@bind="_attributeName" @bind:after="OnManualChangedAsync" />
|
||||
<div class="form-text">MXAccess attribute name.</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -100,6 +100,13 @@
|
||||
[Parameter] public EventCallback<string> CurrentAddressChanged { get; set; }
|
||||
[Parameter, EditorRequired] public Func<string> GetConfigJson { get; set; } = () => "{}";
|
||||
|
||||
/// <summary>True when the currently-selected attribute is itself an alarm condition
|
||||
/// (<c>DriverAttributeInfo.IsAlarm</c>). 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).</summary>
|
||||
[Parameter] public bool SelectedIsAlarm { get; set; }
|
||||
[Parameter] public EventCallback<bool> 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);
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
private async Task OnManualChangedAsync()
|
||||
{
|
||||
await SetSelectedIsAlarmAsync(false);
|
||||
await OnChangedAsync();
|
||||
}
|
||||
|
||||
/// <summary>Publishes the selected attribute's alarm-ness to the host, only when it changed.</summary>
|
||||
private async Task SetSelectedIsAlarmAsync(bool isAlarm)
|
||||
{
|
||||
if (SelectedIsAlarm == isAlarm) { return; }
|
||||
SelectedIsAlarm = isAlarm;
|
||||
await SelectedIsAlarmChanged.InvokeAsync(isAlarm);
|
||||
}
|
||||
|
||||
private string Build()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_tagName))
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
OnPickAddress="@OnGalaxyAddressPicked">
|
||||
<GalaxyAddressPickerBody CurrentAddress="@_galaxyAddress"
|
||||
CurrentAddressChanged="@((s) => _galaxyAddress = s)"
|
||||
@bind-SelectedIsAlarm="_galaxyPickedIsAlarm"
|
||||
GetConfigJson="@(() => SelectedDriverConfig)" />
|
||||
</DriverTagPicker>
|
||||
}
|
||||
@@ -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<string, object> BuildEditorParameters() => new Dictionary<string, object>
|
||||
@@ -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.
|
||||
|
||||
@@ -94,6 +94,41 @@ public sealed class NativeAlarmModel
|
||||
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||
public string? Validate() => null;
|
||||
|
||||
/// <summary>Default <c>alarmType</c> seeded when an alarm-bearing attribute is picked but the
|
||||
/// TagConfig has no <c>alarm</c> object yet. Galaxy's <c>AlarmExtension</c> is a boolean
|
||||
/// off-normal condition, so <c>OffNormalAlarm</c> is the natural Part 9 subtype.</summary>
|
||||
public const string DefaultSeedAlarmType = "OffNormalAlarm";
|
||||
|
||||
/// <summary>Default OPC UA severity (1..1000) seeded for a freshly pre-filled native alarm.</summary>
|
||||
public const int DefaultSeedSeverity = 700;
|
||||
|
||||
/// <summary>
|
||||
/// Pre-seeds a default native-alarm <c>alarm</c> object into <paramref name="json"/> when the
|
||||
/// selected driver attribute is itself an alarm and the TagConfig carries no <c>alarm</c> object
|
||||
/// yet. This is the Galaxy-picker convenience: picking an <c>IsAlarm</c> attribute materialises a
|
||||
/// ready-to-author Part 9 condition (<c>{"alarmType":"OffNormalAlarm","severity":700}</c>) so the
|
||||
/// operator doesn't hand-write the JSON. Every existing key (root + a pre-existing <c>alarm</c>) is
|
||||
/// preserved: when an <c>alarm</c> object already exists this returns the input unchanged (never
|
||||
/// overwrites an authored alarm). Returns the (possibly unchanged) TagConfig JSON string.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Reads a nullable bool: <c>null</c> when absent/null/non-bool; otherwise the bool value.</summary>
|
||||
private static bool? ReadNullableBool(JsonObject o, string name)
|
||||
=> o.TryGetPropertyValue(name, out var n) && n is JsonValue v && v.TryGetValue<bool>(out var b)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="NativeAlarmModel.SeedDefaultAlarm"/> — the Galaxy-picker convenience that
|
||||
/// pre-fills a default native-alarm <c>alarm</c> object when an <c>IsAlarm</c> attribute is selected.
|
||||
/// Seeds only when absent (never overwrites an authored alarm) and preserves every other key.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user