diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawBrowseModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawBrowseModal.razor index 776a5b65..b9a1f03f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawBrowseModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawBrowseModal.razor @@ -503,6 +503,7 @@ string name = sel.Leaf.DisplayName; string? driverDataType = null; IReadOnlyDictionary? addressFields = null; + string? descriptor = null; try { var attrs = await BrowserService.AttributesAsync(_token, nodeId, default); @@ -514,6 +515,8 @@ // reconstructed from the node id: a metric name may contain '/', so the id cannot be // split back into (group, edgeNode, device?, metric) — see RawBrowseCommitMapper. addressFields = attrs[0].AddressFields; + // The descriptor line, so a commit refusal can say WHY a leaf is unbindable (G2-F2). + descriptor = attrs[0].SecurityClass; } } catch @@ -523,7 +526,7 @@ } _selectedIds.Add(nodeId); - _selected[nodeId] = new SelectedLeaf(nodeId, name, driverDataType, sel.FolderPath, addressFields); + _selected[nodeId] = new SelectedLeaf(nodeId, name, driverDataType, sel.FolderPath, addressFields, descriptor); // A metric is also a legal rebirth scope (it resolves up to its owning edge node), so a toggle // moves the scope the same way a folder click does. @@ -768,7 +771,7 @@ // committing it would produce a tag that deploys clean and then reports BadNodeIdUnknown // forever with nothing to point at. _commitErrors = _selected.Values - .Select(s => RawBrowseCommitMapper.DescribeUncommittableLeaf(_driverType, s.Name, s.AddressFields)) + .Select(s => RawBrowseCommitMapper.DescribeUncommittableLeaf(_driverType, s.Name, s.AddressFields, s.Descriptor)) .Where(e => e is not null) .Select(e => e!) .ToList(); @@ -843,7 +846,8 @@ string Name, string? DriverDataType, IReadOnlyList FolderPath, - IReadOnlyDictionary? AddressFields = null); + IReadOnlyDictionary? AddressFields = null, + string? Descriptor = null); /// A described Request-rebirth scope: what gets published, and to what. /// The browse node id handed to the session verbatim. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawBrowseCommitMapper.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawBrowseCommitMapper.cs index febb0cbf..9fb8fdb9 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawBrowseCommitMapper.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawBrowseCommitMapper.cs @@ -361,6 +361,10 @@ public static class RawBrowseCommitMapper TryGetField(addressFields, MqttTagConfigKeys.Topic) ?? fullName); } + /// The phrase the MQTT/Sparkplug browser puts on the descriptor of a metric it refuses to + /// bind (MqttBrowseSession.DescribeMetric); matched case-insensitively. + public const string NotBindableMarker = "not bindable"; + /// /// Describes why a selected browse leaf cannot be committed as a working tag, or null when it /// can. The AdminUI calls this before mapping so an unbindable selection fails at commit, in @@ -369,6 +373,9 @@ public static class RawBrowseCommitMapper /// The owning device's driver type. /// The leaf's browse name, for the message. /// The structured address the browse session stated, if any. + /// The leaf attribute's descriptor line (AttributeInfo.SecurityClass), + /// when the lookup returned one. MQTT uses it to tell a deliberately unbindable Sparkplug metric + /// () from a lookup that returned nothing. /// The operator-facing reason, or null when the leaf is committable. /// /// @@ -394,9 +401,10 @@ public static class RawBrowseCommitMapper public static string? DescribeUncommittableLeaf( string driverType, string browseName, - IReadOnlyDictionary? addressFields) + IReadOnlyDictionary? addressFields, + string? descriptor = null) { - if (Is(driverType, DriverTypeNames.Mqtt)) return DescribeUncommittableMqttLeaf(browseName, addressFields); + if (Is(driverType, DriverTypeNames.Mqtt)) return DescribeUncommittableMqttLeaf(browseName, addressFields, descriptor); if (Is(driverType, DriverTypeNames.Sql)) return DescribeUncommittableSqlLeaf(browseName, addressFields); return null; } @@ -404,13 +412,25 @@ public static class RawBrowseCommitMapper /// Describes an MQTT leaf that states no address at all, or null when it states one. /// The leaf's browse name, for the message. /// The structured address the browse session stated, if any. + /// The attribute's descriptor line (AttributeInfo.SecurityClass), if the + /// lookup returned one. /// The operator-facing reason, or null. private static string? DescribeUncommittableMqttLeaf( - string browseName, IReadOnlyDictionary? addressFields) + string browseName, IReadOnlyDictionary? addressFields, string? descriptor) { if (TryGetField(addressFields, MqttTagConfigKeys.MetricName) is not null) return null; if (TryGetField(addressFields, MqttTagConfigKeys.Topic) is not null) return null; + // G2-F2: the Sparkplug browser deliberately states no address for a metric the ingestor will not + // read (an *Array type, or a type with no driver mapping — R06:M-5), and says so on its descriptor + // line. That is a "not bindable in this version" refusal, not a failed lookup, so say which. + if (descriptor is not null && descriptor.Contains(NotBindableMarker, StringComparison.OrdinalIgnoreCase)) + { + return $"'{browseName}' cannot be bound in this version: the Sparkplug browser reports it as " + + $"\"{descriptor}\". Array metrics and metrics of an unsupported type are not read yet; " + + "remove it from the selection."; + } + return $"'{browseName}' could not be committed: the browse session reported no MQTT address for it " + "(its attribute lookup returned nothing). Re-open the browser and re-select it, or author " + "the tag manually."; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MqttUnbindableLeafMessageTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MqttUnbindableLeafMessageTests.cs new file mode 100644 index 00000000..77c6d3c8 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MqttUnbindableLeafMessageTests.cs @@ -0,0 +1,58 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// G2-F2: a Sparkplug array / unsupported-type metric is refused at commit with a "not bindable in this +/// version" message that echoes the browser's descriptor — not the "attribute lookup returned nothing" +/// text, which is reserved for a lookup that really did return nothing. +/// +[Trait("Category", "Unit")] +public sealed class MqttUnbindableLeafMessageTests +{ + [Fact] + public void An_array_metric_says_it_is_not_bindable_and_echoes_the_descriptor() + { + const string descriptor = "Metric · Int32Array · alias 7 · array metrics are not read in v1 — not bindable"; + + var error = RawBrowseCommitMapper.DescribeUncommittableLeaf("Mqtt", "Pressures", addressFields: null, descriptor); + + error.ShouldNotBeNull(); + error!.ShouldContain("'Pressures' cannot be bound in this version"); + error.ShouldContain(descriptor); + error.ShouldNotContain("attribute lookup returned nothing"); + } + + [Fact] + public void A_lookup_that_returned_nothing_keeps_the_original_message() + { + // Positive control: the new branch is selected by the descriptor, not by MQTT in general. + RawBrowseCommitMapper.DescribeUncommittableLeaf("Mqtt", "Temperature", addressFields: null, descriptor: null)! + .ShouldContain("attribute lookup returned nothing"); + RawBrowseCommitMapper.DescribeUncommittableLeaf("Mqtt", "Temperature", null, "Metric · Double")! + .ShouldContain("attribute lookup returned nothing"); + } + + [Fact] + public void A_bindable_leaf_is_never_refused_whatever_its_descriptor_says() + => RawBrowseCommitMapper.DescribeUncommittableLeaf( + "Mqtt", "Temperature", + new Dictionary { [ZB.MOM.WW.OtOpcUa.Driver.Mqtt.MqttTagConfigKeys.MetricName] = "Temperature" }, + "anything · not bindable") + .ShouldBeNull(); + + [Fact] + public void The_marker_matches_what_the_sparkplug_browser_writes() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx"))) dir = dir.Parent; + var browser = File.ReadAllText(Path.Combine(dir!.FullName, "src", "Drivers", + "ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser", "MqttBrowseSession.cs")); + + browser.ShouldContain("— " + RawBrowseCommitMapper.NotBindableMarker + "\")"); + AdminUiSource.Read("Components", "Shared", "Raw", "RawBrowseModal.razor") + .ShouldContain("DescribeUncommittableLeaf(_driverType, s.Name, s.AddressFields, s.Descriptor)"); + } +}