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:
@@ -619,6 +619,100 @@ public sealed class MqttBrowseSessionTests
|
||||
a.SecurityClass.ShouldContain("unsupported");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SparkplugAttributes_StateTheBindingTuple_SoTheCommitNeverParsesTheNodeId()
|
||||
{
|
||||
// The address of a Sparkplug tag is (group, edgeNode, device?, metric) — NOT the node id. The
|
||||
// session already holds the decomposition (it parsed the birth topic), so it states it; the
|
||||
// AdminUI's browse-commit mapper reads these keys and never splits the id.
|
||||
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
||||
s.ObserveBirthForTest("OtOpcUaSim", "EdgeA", "Filler1", "Temperature", SparkplugDataType.Float);
|
||||
|
||||
var a = (await s.AttributesAsync(
|
||||
"OtOpcUaSim/EdgeA/Filler1::Temperature", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
||||
|
||||
a.AddressFields.ShouldNotBeNull();
|
||||
a.AddressFields![MqttTagConfigKeys.GroupId].ShouldBe("OtOpcUaSim");
|
||||
a.AddressFields[MqttTagConfigKeys.EdgeNodeId].ShouldBe("EdgeA");
|
||||
a.AddressFields[MqttTagConfigKeys.DeviceId].ShouldBe("Filler1");
|
||||
a.AddressFields[MqttTagConfigKeys.MetricName].ShouldBe("Temperature");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SparkplugAttributes_NodeLevelMetric_StatesNoDeviceId()
|
||||
{
|
||||
// An NBIRTH metric belongs to the edge node itself. An invented/blank device id would be a
|
||||
// device that does not exist — the same reason the tree hangs it directly under the edge node.
|
||||
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
||||
s.ObserveBirthForTest("OtOpcUaSim", "EdgeA", null, "Uptime", SparkplugDataType.Int64);
|
||||
|
||||
var a = (await s.AttributesAsync(
|
||||
"OtOpcUaSim/EdgeA::Uptime", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
||||
|
||||
a.AddressFields.ShouldNotBeNull();
|
||||
a.AddressFields!.ContainsKey(MqttTagConfigKeys.DeviceId).ShouldBeFalse();
|
||||
a.AddressFields[MqttTagConfigKeys.EdgeNodeId].ShouldBe("EdgeA");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SparkplugAttributes_MetricNameContainingASlash_IsStatedWhole()
|
||||
{
|
||||
// 'Node Control/Rebirth' is a canonical Sparkplug metric name. Its node id is
|
||||
// 'OtOpcUaSim/EdgeA::Node Control/Rebirth' — un-splittable back into the tuple, which is why the
|
||||
// tuple travels rather than the id.
|
||||
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
||||
s.ObserveBirthForTest("OtOpcUaSim", "EdgeA", null, "Node Control/Rebirth", SparkplugDataType.Boolean);
|
||||
|
||||
var a = (await s.AttributesAsync(
|
||||
"OtOpcUaSim/EdgeA::Node Control/Rebirth", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
||||
|
||||
a.AddressFields.ShouldNotBeNull();
|
||||
a.AddressFields![MqttTagConfigKeys.MetricName].ShouldBe("Node Control/Rebirth");
|
||||
a.AddressFields[MqttTagConfigKeys.EdgeNodeId].ShouldBe("EdgeA");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SparkplugAttributes_DeviceAndNodeLevelMetricsOfTheSameName_StateDifferentAddresses()
|
||||
{
|
||||
// The two are siblings sharing a label ('EdgeA/Temp' the device folder vs 'EdgeA::Temp' the
|
||||
// node-level metric). Anything that re-derived the address from the label would collapse them.
|
||||
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
||||
s.ObserveBirthForTest("OtOpcUaSim", "EdgeA", null, "Temp", SparkplugDataType.Float);
|
||||
s.ObserveBirthForTest("OtOpcUaSim", "EdgeA", "Filler1", "Temp", SparkplugDataType.Float);
|
||||
|
||||
var nodeLevel = (await s.AttributesAsync(
|
||||
"OtOpcUaSim/EdgeA::Temp", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
||||
var deviceLevel = (await s.AttributesAsync(
|
||||
"OtOpcUaSim/EdgeA/Filler1::Temp", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
||||
|
||||
nodeLevel.AddressFields!.ContainsKey(MqttTagConfigKeys.DeviceId).ShouldBeFalse();
|
||||
deviceLevel.AddressFields![MqttTagConfigKeys.DeviceId].ShouldBe("Filler1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlainAttributes_StateTheTopic_AsTheAddress()
|
||||
{
|
||||
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
s.ObserveTopicForTest("otopcua/fixture/oven/temp", "21.5");
|
||||
|
||||
var a = (await s.AttributesAsync(
|
||||
"otopcua/fixture/oven/temp", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
||||
|
||||
a.AddressFields.ShouldNotBeNull();
|
||||
a.AddressFields![MqttTagConfigKeys.Topic].ShouldBe("otopcua/fixture/oven/temp");
|
||||
a.AddressFields.ContainsKey(MqttTagConfigKeys.MetricName).ShouldBeFalse(); // Plain has no metric
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(MqttMode.SparkplugB, true)]
|
||||
[InlineData(MqttMode.Plain, false)]
|
||||
public void RebirthAvailable_IsSparkplugOnly(MqttMode mode, bool expected)
|
||||
{
|
||||
// The picker draws its Request-rebirth affordance off this. A plain window has no re-announce
|
||||
// action at all — RequestRebirthAsync refuses one — so offering the button would be a lie.
|
||||
new MqttBrowseSession(mode).RebirthAvailable.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SparkplugAttributes_ForAFolderNode_AreEmpty()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user