From 15bd409cdda4136d5aff7afa0a531e0a9673a6ee Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 18:01:44 -0400 Subject: [PATCH] fix(adminui): say why a Sparkplug array/unsupported metric cannot be committed (G2-F2) Since R06:M-5 the Sparkplug browser states no address for a metric the ingestor will not read and labels its descriptor 'not bindable', but the commit refusal still said the attribute lookup returned nothing. RawBrowseModal keeps the attribute's descriptor on the selection, and DescribeUncommittableLeaf reports a 'cannot be bound in this version' message echoing it. A lookup that really returned nothing keeps the old text. A test pins the marker to the browser's own string. Refs #745 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../Shared/Raw/RawBrowseModal.razor | 10 +++- .../Uns/RawBrowseCommitMapper.cs | 26 ++++++++- .../Uns/MqttUnbindableLeafMessageTests.cs | 58 +++++++++++++++++++ 3 files changed, 88 insertions(+), 6 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MqttUnbindableLeafMessageTests.cs 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)"); + } +}