fix(mqtt): browse-commit writes a bindable address + a Request-rebirth affordance
Two gaps the Task 23/24 review found, both blocking Task 26's live gate.
Gap 1 — browse-commit produced a silently dead MQTT tag. RawBrowseCommitMapper
had no `Mqtt` case, so a committed leaf fell through to the generic
`{"address": …}` key. Neither MqttTagDefinitionFactory entry point reads
`address`: the tag deployed clean and reported BadNodeIdUnknown forever, with
no signal at commit time. Predates Task 23 (Plain was affected too), but Task 23
built the Sparkplug metric tree precisely so an operator could browse and commit
a binding.
The address is a DESCRIPTOR, not a reference string, and it cannot be recovered
from the browse node id: `{group}/{node}[/{device}]::{metric}` where a metric
name legitimately contains `/` (`Node Control/Rebirth`) — the ambiguity
MetricSeparator's remarks already name. So the session STATES it, via a new
`AttributeInfo.AddressFields` seam, and the mapper reads it. Which keys are
emitted is also how the mapper learns Plain vs Sparkplug — the driver type
reaching it is just `Mqtt`, and the mode lives on the driver config. Key names
are single-sourced in the new `MqttTagConfigKeys` (producer, mapper, factory),
with the literals pinned by a test so a symmetric rename cannot silently unbind
already-persisted blobs. A leaf with no stated address is refused at commit in
words rather than committed dead.
Gap 2 — RequestRebirthAsync had no UI. Task 23 shipped it backend-only; Task 26
step 1 assumes the button, and Task 25's runbook notes the picker tree stays
empty until a birth lands, so it is the only way to fill it on demand. Added to
the /raw browse modal: scope = the clicked tree node (device/metric resolve up to
their edge node, group fans out and is refused whole past 32), an explicit
two-click confirm naming the resolved scope and its consequence, the outcome or
the refusal shown rather than swallowed. Offered only for a Sparkplug session
(new `IRebirthCapableBrowseSession.RebirthAvailable` — one session class serves
both modes, so a type test alone would draw a button that can only throw) and
only to a DriverOperator; the server-side gate remains the boundary.
Tests: MQTT 545 → 581, AdminUI 781 → 800. The round-trip tests feed the emitted
blob to the REAL factory and were falsified by mutating the emitted key name.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -213,6 +213,14 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
|
||||
/// </summary>
|
||||
internal MqttMode Mode { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Sparkplug only. A plain-MQTT window has no re-announce action at all — see
|
||||
/// <see cref="RequestRebirthAsync"/>, which refuses one — so the picker must not draw the
|
||||
/// affordance for it.
|
||||
/// </remarks>
|
||||
public bool RebirthAvailable => Mode == MqttMode.SparkplugB;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Guid Token { get; } = Guid.NewGuid();
|
||||
|
||||
@@ -301,11 +309,26 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
|
||||
{
|
||||
if (node.Metric is { } metric)
|
||||
{
|
||||
attrs = [DescribeMetric(metric)];
|
||||
attrs = [DescribeMetric(metric, node.Scope)];
|
||||
}
|
||||
else if (node.Observed is { } observed)
|
||||
{
|
||||
attrs = [new AttributeInfo(node.Segment, observed.DataType.ToString(), IsArray: false, observed.Snippet)];
|
||||
attrs =
|
||||
[
|
||||
new AttributeInfo(
|
||||
node.Segment,
|
||||
observed.DataType.ToString(),
|
||||
IsArray: false,
|
||||
observed.Snippet,
|
||||
// Plain mode's address IS the node id, but it is stated rather than left implicit
|
||||
// for the same reason the Sparkplug tuple is: the commit mapper reads the address
|
||||
// off the fields, never off the id, and "which keys are present" is how it learns
|
||||
// which of the driver's two binding shapes this leaf is.
|
||||
AddressFields: new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
[MqttTagConfigKeys.Topic] = node.Path,
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,8 +340,10 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
|
||||
/// Projects one observed Sparkplug birth metric into the picker's attribute row.
|
||||
/// </summary>
|
||||
/// <param name="metric">The metric as its birth certificate declared it.</param>
|
||||
/// <param name="scope">The <c>(group, edgeNode, device?)</c> identity stamped on the metric's node.</param>
|
||||
/// <returns>The attribute row.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="SparkplugDataTypeExtensions.ToDriverDataType"/> returns <see langword="null"/> for
|
||||
/// the five v1-unsupported datatypes (<c>Unknown</c>, <c>DataSet</c>, <c>Template</c>,
|
||||
/// <c>PropertySet</c>, <c>PropertySetList</c>), and that null is passed through as the Sparkplug
|
||||
@@ -326,8 +351,18 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
|
||||
/// <see cref="DriverDataType"/>, so the AdminUI's browse-commit mapper falls back to its
|
||||
/// declared default instead of silently binding a metric this driver cannot serve — and the
|
||||
/// descriptor line says so in words.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b><see cref="AttributeInfo.AddressFields"/> is what makes a browse-committed Sparkplug tag
|
||||
/// bindable at all.</b> The tag's address is the <c>(group, edgeNode, device?, metric)</c> tuple,
|
||||
/// and that tuple is <i>not</i> recoverable from the node id: a metric name may contain <c>/</c>
|
||||
/// (and, off an arbitrary broker, <c>::</c>), which is exactly why <see cref="MetricSeparator"/>'s
|
||||
/// remarks say nothing re-parses it. So the decomposition this session already holds — stamped on
|
||||
/// the node when the birth was decoded — is handed over verbatim, keyed by
|
||||
/// <see cref="MqttTagConfigKeys"/>, the same names <c>MqttTagDefinitionFactory</c> reads.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static AttributeInfo DescribeMetric(SparkplugMetricInfo metric)
|
||||
private static AttributeInfo DescribeMetric(SparkplugMetricInfo metric, SparkplugScope? scope)
|
||||
{
|
||||
var mapped = metric.DataType?.ToDriverDataType();
|
||||
var sparkplugName = metric.DataType?.ToString() ?? nameof(SparkplugDataType.Unknown);
|
||||
@@ -340,7 +375,36 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
|
||||
metric.Name,
|
||||
mapped?.ToString() ?? sparkplugName,
|
||||
metric.DataType?.IsSparkplugArray() ?? false,
|
||||
descriptor.ToString());
|
||||
descriptor.ToString(),
|
||||
AddressFields: BuildMetricAddressFields(metric.Name, scope));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the Sparkplug binding tuple a browse-commit writes into the tag's <c>TagConfig</c>.
|
||||
/// </summary>
|
||||
/// <param name="metricName">The metric's birth-declared name.</param>
|
||||
/// <param name="scope">The metric node's stamped identity, or <see langword="null"/>.</param>
|
||||
/// <returns>
|
||||
/// The address fields, or <see langword="null"/> when the node carries no scope (which a
|
||||
/// Sparkplug metric node never does — every one is created with the scope its birth parsed —
|
||||
/// so a null here is "the producer cannot state the address", never "the address is empty").
|
||||
/// </returns>
|
||||
private static IReadOnlyDictionary<string, string>? BuildMetricAddressFields(
|
||||
string metricName, SparkplugScope? scope)
|
||||
{
|
||||
if (scope?.EdgeNodeId is not { } edgeNodeId) return null;
|
||||
|
||||
var fields = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
[MqttTagConfigKeys.GroupId] = scope.GroupId,
|
||||
[MqttTagConfigKeys.EdgeNodeId] = edgeNodeId,
|
||||
[MqttTagConfigKeys.MetricName] = metricName,
|
||||
};
|
||||
|
||||
// Omitted, not blank, for a node-level metric: the factory normalises "" to absent anyway, but
|
||||
// an emitted empty device id would read as "a device whose id is empty" to anything else looking.
|
||||
if (scope.DeviceId is { } deviceId) fields[MqttTagConfigKeys.DeviceId] = deviceId;
|
||||
return fields;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -489,13 +553,16 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
|
||||
return;
|
||||
}
|
||||
|
||||
var scope = new SparkplugScope(groupId, edgeNodeId);
|
||||
// Two scopes, and the difference is load-bearing: the edge node's own scope has no device, and
|
||||
// stamping the device one on it would make an NBIRTH metric claim a device it does not belong to.
|
||||
var edgeScope = new SparkplugScope(groupId, edgeNodeId);
|
||||
var ownerScope = deviceId is null ? edgeScope : edgeScope with { DeviceId = deviceId };
|
||||
|
||||
var group = GetOrCreate(_roots, groupId, groupId, BrowseNodeKind.Folder, new SparkplugScope(groupId, null));
|
||||
if (group is null) return;
|
||||
|
||||
var edgePath = groupId + "/" + edgeNodeId;
|
||||
var edge = GetOrCreate(group.Children, edgePath, edgeNodeId, BrowseNodeKind.Folder, scope);
|
||||
var edge = GetOrCreate(group.Children, edgePath, edgeNodeId, BrowseNodeKind.Folder, edgeScope);
|
||||
if (edge is null) return;
|
||||
|
||||
var owner = edge;
|
||||
@@ -503,7 +570,7 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
|
||||
if (deviceId is not null)
|
||||
{
|
||||
ownerPath = edgePath + "/" + deviceId;
|
||||
owner = GetOrCreate(edge.Children, ownerPath, deviceId, BrowseNodeKind.Folder, scope);
|
||||
owner = GetOrCreate(edge.Children, ownerPath, deviceId, BrowseNodeKind.Folder, ownerScope);
|
||||
if (owner is null) return;
|
||||
}
|
||||
|
||||
@@ -525,7 +592,7 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
|
||||
continue;
|
||||
}
|
||||
|
||||
var node = GetOrCreate(owner.Children, metricPath, name, BrowseNodeKind.Leaf, scope);
|
||||
var node = GetOrCreate(owner.Children, metricPath, name, BrowseNodeKind.Leaf, ownerScope);
|
||||
if (node is null) return; // node cap tripped
|
||||
|
||||
node.RecordMetric(new SparkplugMetricInfo(name, metric.DataType, parsed.Type, metric.Alias));
|
||||
@@ -956,7 +1023,13 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
|
||||
/// The owning edge-node id, or <see langword="null"/> on a group node (which addresses every
|
||||
/// edge node beneath it).
|
||||
/// </param>
|
||||
private sealed record SparkplugScope(string GroupId, string? EdgeNodeId);
|
||||
/// <param name="DeviceId">
|
||||
/// The owning device id, or <see langword="null"/> when the node belongs to the edge node
|
||||
/// itself. Carried for the <b>address</b> (a tag's binding tuple), not for rebirth — Sparkplug
|
||||
/// has no device-scoped rebirth, so <see cref="ResolveRebirthTargets"/> reads only the pair
|
||||
/// above.
|
||||
/// </param>
|
||||
private sealed record SparkplugScope(string GroupId, string? EdgeNodeId, string? DeviceId = null);
|
||||
|
||||
/// <summary>One metric, exactly as a birth certificate declared it. No payload bytes are retained.</summary>
|
||||
/// <param name="Name">The metric name — the tag's stable binding key across rebirths.</param>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>TagConfig</c> JSON key names that make up an MQTT tag's <b>address</b>, in the two shapes
|
||||
/// the driver binds by: a single <see cref="Topic"/> (Plain) or the
|
||||
/// <see cref="GroupId"/>/<see cref="EdgeNodeId"/>/<see cref="DeviceId"/>/<see cref="MetricName"/>
|
||||
/// tuple (Sparkplug B).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// These exist because the address is produced in one place and consumed in another, and the
|
||||
/// two used to agree only by coincidence: the AdminUI browse-commit mapper writes the blob,
|
||||
/// <see cref="MqttTagDefinitionFactory"/> reads it, and a key-name drift between them is not a
|
||||
/// compile error — it is a tag that deploys clean, resolves to nothing, and reports
|
||||
/// <c>BadNodeIdUnknown</c> at runtime with no authoring-time signal at all. Single-sourcing
|
||||
/// the names makes that drift impossible rather than merely unlikely.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Address keys only.</b> The behavioural keys (<c>payloadFormat</c>, <c>jsonPath</c>,
|
||||
/// <c>dataType</c>, <c>qos</c>, <c>retainSeed</c>) are deliberately not here — nothing produces
|
||||
/// them from a browse, so they carry no cross-component drift risk.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class MqttTagConfigKeys
|
||||
{
|
||||
/// <summary>The concrete MQTT topic a Plain-mode tag subscribes to.</summary>
|
||||
public const string Topic = "topic";
|
||||
|
||||
/// <summary>The Sparkplug group id — the first segment of the tag's binding tuple.</summary>
|
||||
public const string GroupId = "groupId";
|
||||
|
||||
/// <summary>The Sparkplug edge-node id.</summary>
|
||||
public const string EdgeNodeId = "edgeNodeId";
|
||||
|
||||
/// <summary>The Sparkplug device id; absent for a metric published by the edge node itself.</summary>
|
||||
public const string DeviceId = "deviceId";
|
||||
|
||||
/// <summary>The Sparkplug metric's stable name — the tag's binding key across rebirths.</summary>
|
||||
public const string MetricName = "metricName";
|
||||
}
|
||||
@@ -77,7 +77,7 @@ public static class MqttTagDefinitionFactory
|
||||
|
||||
// topic is the whole point of a Plain-mode tag — without one there is nothing to subscribe
|
||||
// to, so an absent/blank value is a hard reject rather than a defaulted empty subscription.
|
||||
var topic = ReadString(root, "topic");
|
||||
var topic = ReadString(root, MqttTagConfigKeys.Topic);
|
||||
if (string.IsNullOrWhiteSpace(topic)) return false;
|
||||
|
||||
// Strict enum reads: a present-but-invalid (typo'd) value rejects the whole tag
|
||||
@@ -154,9 +154,9 @@ public static class MqttTagDefinitionFactory
|
||||
// The binding tuple. Without all three of these there is nothing a birth certificate could
|
||||
// ever bind the tag to, so an absent/blank value is a hard reject (→ BadNodeIdUnknown)
|
||||
// rather than a definition that can never resolve and reports nothing about why.
|
||||
var groupId = ReadString(root, "groupId");
|
||||
var edgeNodeId = ReadString(root, "edgeNodeId");
|
||||
var metricName = ReadString(root, "metricName");
|
||||
var groupId = ReadString(root, MqttTagConfigKeys.GroupId);
|
||||
var edgeNodeId = ReadString(root, MqttTagConfigKeys.EdgeNodeId);
|
||||
var metricName = ReadString(root, MqttTagConfigKeys.MetricName);
|
||||
if (string.IsNullOrWhiteSpace(groupId)
|
||||
|| string.IsNullOrWhiteSpace(edgeNodeId)
|
||||
|| string.IsNullOrWhiteSpace(metricName))
|
||||
@@ -166,7 +166,7 @@ public static class MqttTagDefinitionFactory
|
||||
|
||||
// Optional: a node-level metric has no device segment. Blank normalises to absent so
|
||||
// "deviceId":"" and an omitted key describe the same scope rather than two.
|
||||
var deviceId = ReadString(root, "deviceId");
|
||||
var deviceId = ReadString(root, MqttTagConfigKeys.DeviceId);
|
||||
if (string.IsNullOrWhiteSpace(deviceId)) deviceId = null;
|
||||
|
||||
// Strict, but only when present — see the remarks.
|
||||
@@ -180,7 +180,7 @@ public static class MqttTagDefinitionFactory
|
||||
Name: rawPath,
|
||||
// Plain-shape fields carried through verbatim; the Sparkplug ingest path reads none of
|
||||
// them, and a retyped blob's leftovers must not change what the tag means.
|
||||
Topic: ReadString(root, "topic"),
|
||||
Topic: ReadString(root, MqttTagConfigKeys.Topic),
|
||||
PayloadFormat: MqttPayloadFormat.Json,
|
||||
JsonPath: RootJsonPath,
|
||||
DataType: dataType,
|
||||
|
||||
Reference in New Issue
Block a user