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; /// /// Live gate for structured Sparkplug metrics (Gitea #515): a Template and a /// DataSet published by a real edge node over the Mosquitto fixture, flattened into /// addressable OPC UA tags. /// /// /// /// What this adds over the offline suite. The offline tests build decoded payloads by /// hand, so they prove the mapping but not the wire: 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 /// alias and nothing else, so the member names cannot be composed until the alias table /// has resolved the parent. /// /// /// The issue's acceptance criterion, verbatim: "a simulator publishing a Template and a /// DataSet metric produces addressable, correctly-typed OPC UA nodes, live-verified; /// every still-unsupported kind continues to skip with a warning." /// /// [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); } /// Records every published data change, so a test can wait on one and count others. private sealed class Recorder { private readonly List _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 WaitAsync( string rawPath, Func 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); } } } }