Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/SparkplugStructuredLiveTests.cs
T
Joseph Doherty c74ccc55b5 feat(mqtt): flatten Sparkplug Template/DataSet metrics to sibling tags (#515)
`SparkplugCodec` handled scalars; `DataSet`, `Template` and `PropertySet` were skipped
with a warning. They now flatten to addressable tags (owner decision, recorded on the
issue):

  Template  → sibling metrics, `/`-joined, recursively: Motor → Motor/Speed, Motor/Stator/Temp
  DataSet   → one metric per cell, {column}/{rowIndex}: Recipe → Recipe/Step/0, Recipe/Target/1
  PropertySet → node metadata, never tags — it describes a metric rather than being one

Flattening was chosen over OPC UA Structures because flattened scalars are consumable by
any client with no type dictionary, and the result mirrors how the `/raw` tree works.

**The row index is always present, including on row 0.** Emitting a bare column name for
a single-row table and an indexed one otherwise would make a tag's name depend on how many
rows the plant published — a table growing from one row to two would rename every tag
already authored against it.

**A DATA metric carries the parent's alias and no name**, so member names cannot be
composed until the alias table has resolved the parent. Expansion therefore happens in
`SparkplugIngestor.PublishStructuredMembers` — after resolution, at the single funnel both
the DATA and birth-value paths already pass through — not at decode time. The alias table
installs a binding per member so browse enumerates them and a DDATA member that omits its
datatype falls back to the birth's (guessing from the CLR type would read a signed Int32 of
-42 out of its unsigned field as 4294967254).

**Skip-and-warn survives, and refusal is whole** — the explicit requirement on the issue.
A template definition, an unnamed member, an extension value, a column/type mismatch, a
short row, or a payload past MaxDepth(5)/MaxMembers(512) refuses the ENTIRE metric. A
silently half-flattened UDT looks exactly like a complete one in the picker.

The browse picker offers the members and deliberately NOT the structured parent, which has
no value and no servable datatype. The rediscovery signature likewise tracks members rather
than the parent, or a UDT gaining a member would leave the signature unmoved.

Member paths contain `/`, which #509 already solved: the slashed name travels intact as
`metricName` while the derived tag Name is coerced to a legal RawPath segment.

Live-gated 3/3 against the Mosquitto fixture with the project-owned simulator extended to
publish a nested Template and a 2x2 DataSet. Falsified at three points: dropping the member
bindings, not expanding the parent, and refusing to flatten templates each turn exactly the
expected tests red.

Also fixes a regression from the #511 commit in the same branch: `AddOtOpcUaDriverFactories`
resolved `IDriverPrimaryRoleSource` with `GetRequiredService` but only `AddOtOpcUaRuntime`
registered it, so a graph wiring driver factories without the full runtime failed at
resolution. Two Host integration suites do exactly that and caught it; both extensions now
TryAdd the same concrete `RedundancyRoleView`, so the registration is order-independent and
the fail-loud resolve is kept.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-07-31 08:14:04 -04:00

333 lines
14 KiB
C#

using Org.Eclipse.Tahu.Protobuf;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.SparkplugSimulator;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests;
/// <summary>
/// Live gate for structured Sparkplug metrics (Gitea #515): a <c>Template</c> and a
/// <c>DataSet</c> published by a real edge node over the Mosquitto fixture, flattened into
/// addressable OPC UA tags.
/// </summary>
/// <remarks>
/// <para>
/// <b>What this adds over the offline suite.</b> The offline tests build decoded payloads by
/// hand, so they prove the mapping but not the <i>wire</i>: that a nested template really does
/// round-trip through protobuf with its member names intact, and — the case a naive
/// implementation gets wrong — that a DDATA structured metric arrives carrying the parent's
/// <b>alias and nothing else</b>, so the member names cannot be composed until the alias table
/// has resolved the parent.
/// </para>
/// <para>
/// The issue's acceptance criterion, verbatim: "a simulator publishing a <c>Template</c> and a
/// <c>DataSet</c> metric produces addressable, correctly-typed OPC UA nodes, live-verified;
/// every still-unsupported kind continues to skip with a warning."
/// </para>
/// </remarks>
[Trait("Category", "Integration")]
[Collection("MqttFixture")]
public sealed class SparkplugStructuredLiveTests(MqttFixture fixture)
{
private const uint Good = 0x00000000u;
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(20);
private void SkipIfUnconfigured() => Assert.SkipWhen(fixture.SkipReason is not null, fixture.SkipReason ?? "");
[Fact]
public async Task A_template_metric_flattens_to_addressable_tags_across_birth_and_data()
{
SkipIfUnconfigured();
var ct = TestContext.Current.CancellationToken;
var group = NewGroup("tmpl");
const string node = "EdgeA";
const string device = "Filler1";
const string speedPath = "Fixture/Sparkplug/Motor/Speed";
const string runningPath = "Fixture/Sparkplug/Motor/Running";
const string statorPath = "Fixture/Sparkplug/Motor/Stator/Temp";
await using var sim = new SparkplugEdgeNode(SimOptions(group, node));
sim.SetDeviceMetrics(device, new SparkplugMetricSeed("Motor", 13, DataType.Template, MotorTemplate(speed: 1500, running: true, statorTemp: 71.5)));
await sim.ConnectAsync(ct);
await sim.BirthAsync(ct);
await using var driver = new MqttDriver(
SparkplugOptions(
group,
Tag(speedPath, group, node, device, "Motor/Speed"),
Tag(runningPath, group, node, device, "Motor/Running"),
Tag(statorPath, group, node, device, "Motor/Stator/Temp")),
"mqtt-live-sp-template");
var recorder = new Recorder(driver);
await driver.InitializeAsync(string.Empty, ct);
await driver.SubscribeAsync([speedPath, runningPath, statorPath], TimeSpan.FromSeconds(1), ct);
// The DBIRTH is a full snapshot, so these are the birth's own member values — including the
// NESTED template's, which is what proves the `/`-joined path composes past one level.
var birth = await recorder.WaitAsync(speedPath, v => Equals(v.Value, 1500d), Timeout, ct);
birth.Snapshot.StatusCode.ShouldBe(Good);
await recorder.WaitAsync(runningPath, v => Equals(v.Value, true), Timeout, ct);
await recorder.WaitAsync(statorPath, v => Equals(v.Value, 71.5d), Timeout, ct);
// …and now DDATA. The metric on the wire carries the PARENT's alias and no name at all, so a
// driver that flattened only at decode time — without resolving the parent alias to its name
// first — would have nothing to compose `Motor/Speed` from and would feed none of these.
await sim.PublishDeviceDataAsync(
device, [("Motor", MotorTemplate(speed: 1750, running: false, statorTemp: 68.25))], ct);
var data = await recorder.WaitAsync(speedPath, v => Equals(v.Value, 1750d), Timeout, ct);
data.Snapshot.StatusCode.ShouldBe(Good);
data.FullReference.ShouldBe(speedPath, "values publish under the tag's RawPath, never the metric name");
await recorder.WaitAsync(runningPath, v => Equals(v.Value, false), Timeout, ct);
await recorder.WaitAsync(statorPath, v => Equals(v.Value, 68.25d), Timeout, ct);
// The read path serves the same last values under the same keys.
var read = await driver.ReadAsync([speedPath, runningPath, statorPath], ct);
read.Select(r => r.StatusCode).ShouldAllBe(s => s == Good);
read[0].Value.ShouldBe(1750d);
}
[Fact]
public async Task A_dataset_metric_flattens_to_one_tag_per_cell()
{
SkipIfUnconfigured();
var ct = TestContext.Current.CancellationToken;
var group = NewGroup("dset");
const string node = "EdgeA";
const string device = "Filler1";
const string step0Path = "Fixture/Sparkplug/Recipe/Step0";
const string target1Path = "Fixture/Sparkplug/Recipe/Target1";
await using var sim = new SparkplugEdgeNode(SimOptions(group, node));
sim.SetDeviceMetrics(device, new SparkplugMetricSeed("Recipe", 14, DataType.DataSet, RecipeDataSet(baseTarget: 20d)));
await sim.ConnectAsync(ct);
await sim.BirthAsync(ct);
await using var driver = new MqttDriver(
SparkplugOptions(
group,
// The row index is always present, including on row 0 — the naming is uniform so a
// table that grows a row never renames the tags already authored against it.
Tag(step0Path, group, node, device, "Recipe/Step/0"),
Tag(target1Path, group, node, device, "Recipe/Target/1")),
"mqtt-live-sp-dataset");
var recorder = new Recorder(driver);
await driver.InitializeAsync(string.Empty, ct);
await driver.SubscribeAsync([step0Path, target1Path], TimeSpan.FromSeconds(1), ct);
// Cells are typed per COLUMN: Step is Int32, Target is Double. A dataset whose columns and
// types disagree is refused outright, so a wrong type here is not a near-miss — it is the
// difference between a typed tag and no tag.
await recorder.WaitAsync(step0Path, v => Equals(v.Value, 0), Timeout, ct);
await recorder.WaitAsync(target1Path, v => Equals(v.Value, 21d), Timeout, ct);
await sim.PublishDeviceDataAsync(device, [("Recipe", RecipeDataSet(baseTarget: 30d))], ct);
var target = await recorder.WaitAsync(target1Path, v => Equals(v.Value, 31d), Timeout, ct);
target.Snapshot.StatusCode.ShouldBe(Good);
}
[Fact]
public async Task A_template_definition_is_still_skipped_rather_than_flattened()
{
// Skip-and-warn survives — the requirement the issue is explicit about. A definition names its
// members and carries no values, so flattening it would materialise tags that can never read,
// indistinguishable in the picker from an instance's.
SkipIfUnconfigured();
var ct = TestContext.Current.CancellationToken;
var group = NewGroup("tdef");
const string node = "EdgeA";
const string device = "Filler1";
const string speedPath = "Fixture/Sparkplug/Proto/Speed";
const string livePath = "Fixture/Sparkplug/Proto/Live";
var definition = new Payload.Types.Template { Version = "1", IsDefinition = true };
definition.Metrics.Add(new Payload.Types.Metric { Name = "Speed", Datatype = (uint)DataType.Double });
await using var sim = new SparkplugEdgeNode(SimOptions(group, node));
sim.SetDeviceMetrics(
device,
new SparkplugMetricSeed("MotorType", 15, DataType.Template, definition),
// The positive control, and it is load-bearing: without it "Speed never arrived" is
// satisfied by any failure to connect, birth or subscribe, and the test would pass against
// a driver that ingests nothing at all.
new SparkplugMetricSeed("Live", 16, DataType.Double, 42.5d));
await sim.ConnectAsync(ct);
await sim.BirthAsync(ct);
await using var driver = new MqttDriver(
SparkplugOptions(
group,
Tag(speedPath, group, node, device, "MotorType/Speed"),
Tag(livePath, group, node, device, "Live")),
"mqtt-live-sp-tdef");
var recorder = new Recorder(driver);
await driver.InitializeAsync(string.Empty, ct);
await driver.SubscribeAsync([speedPath, livePath], TimeSpan.FromSeconds(1), ct);
// Wait for the control FIRST, so the absence assertion below is made at a moment the birth is
// known to have been processed.
await recorder.WaitAsync(livePath, v => Equals(v.Value, 42.5d), Timeout, ct);
recorder.Count(speedPath).ShouldBe(0, "a template definition carries no values to publish");
var read = await driver.ReadAsync([speedPath], ct);
read[0].StatusCode.ShouldNotBe(Good, "a tag bound to an unflattened member has never had a value");
}
// ---- helpers -----------------------------------------------------------------------------------
private static string NewGroup(string label) => $"OtOpcUaLive-{label}-{Guid.NewGuid():N}"[..40];
private static Payload.Types.Template MotorTemplate(double speed, bool running, double statorTemp)
{
var stator = new Payload.Types.Template { Version = "1", IsDefinition = false };
stator.Metrics.Add(new Payload.Types.Metric
{
Name = "Temp", Datatype = (uint)DataType.Double, DoubleValue = statorTemp,
});
var motor = new Payload.Types.Template { Version = "1", IsDefinition = false };
motor.Metrics.Add(new Payload.Types.Metric
{
Name = "Speed", Datatype = (uint)DataType.Double, DoubleValue = speed,
});
motor.Metrics.Add(new Payload.Types.Metric
{
Name = "Running", Datatype = (uint)DataType.Boolean, BooleanValue = running,
});
motor.Metrics.Add(new Payload.Types.Metric
{
Name = "Stator", Datatype = (uint)DataType.Template, TemplateValue = stator,
});
return motor;
}
private static Payload.Types.DataSet RecipeDataSet(double baseTarget)
{
var dataSet = new Payload.Types.DataSet { NumOfColumns = 2UL };
dataSet.Columns.Add("Step");
dataSet.Columns.Add("Target");
dataSet.Types_.Add((uint)DataType.Int32);
dataSet.Types_.Add((uint)DataType.Double);
for (var row = 0; row < 2; row++)
{
var r = new Payload.Types.DataSet.Types.Row();
r.Elements.Add(new Payload.Types.DataSet.Types.DataSetValue { IntValue = (uint)row });
r.Elements.Add(new Payload.Types.DataSet.Types.DataSetValue { DoubleValue = baseTarget + row });
dataSet.Rows.Add(r);
}
return dataSet;
}
private MqttDriverOptions SparkplugOptions(string groupId, params RawTagEntry[] rawTags) => new()
{
Host = fixture.TlsHost,
Port = fixture.TlsPort,
UseTls = true,
CaCertificatePath = fixture.CaCertificatePath,
AllowUntrustedServerCertificate = fixture.CaCertificatePath is null,
Username = fixture.Username,
Password = fixture.Password,
ClientId = $"otopcua-live-spx-{Guid.NewGuid():N}",
ConnectTimeoutSeconds = 15,
Mode = MqttMode.SparkplugB,
Sparkplug = new MqttSparkplugOptions { GroupId = groupId, RequestRebirthOnGap = true },
RawTags = rawTags,
};
private SparkplugEdgeNodeOptions SimOptions(string groupId, string edgeNodeId) => new()
{
Host = fixture.TlsHost,
Port = fixture.TlsPort,
UseTls = true,
CaCertificatePath = fixture.CaCertificatePath,
AllowUntrustedServerCertificate = fixture.CaCertificatePath is null,
Username = fixture.Username,
Password = fixture.Password,
GroupId = groupId,
EdgeNodeId = edgeNodeId,
};
private static RawTagEntry Tag(
string rawPath, string groupId, string edgeNodeId, string? deviceId, string metricName)
{
var device = deviceId is null ? "" : $"\"deviceId\":\"{deviceId}\",";
return new RawTagEntry(
rawPath,
$$"""{"groupId":"{{groupId}}","edgeNodeId":"{{edgeNodeId}}",{{device}}"metricName":"{{metricName}}"}""",
WriteIdempotent: false);
}
/// <summary>Records every published data change, so a test can wait on one and count others.</summary>
private sealed class Recorder
{
private readonly List<DataChangeEventArgs> _events = [];
private readonly Lock _gate = new();
public Recorder(MqttDriver driver) => driver.OnDataChange += (_, e) =>
{
lock (_gate)
{
_events.Add(e);
}
};
public int Count(string rawPath)
{
lock (_gate)
{
return _events.Count(e => e.FullReference == rawPath);
}
}
public async Task<DataChangeEventArgs> WaitAsync(
string rawPath, Func<DataValueSnapshot, bool> predicate, TimeSpan timeout, CancellationToken ct)
{
var deadline = DateTime.UtcNow + timeout;
while (true)
{
lock (_gate)
{
foreach (var candidate in _events)
{
if (candidate.FullReference == rawPath && predicate(candidate.Snapshot))
{
return candidate;
}
}
}
if (DateTime.UtcNow >= deadline)
{
lock (_gate)
{
throw new TimeoutException(
$"No matching value for '{rawPath}' within {timeout}. Seen: "
+ string.Join(", ", _events.Select(e => $"{e.FullReference}={e.Snapshot.Value}")));
}
}
await Task.Delay(100, ct);
}
}
}
}