diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/Protos/sparkplug_b.proto b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/Protos/sparkplug_b.proto new file mode 100644 index 00000000..9bbdc73a --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/Protos/sparkplug_b.proto @@ -0,0 +1,261 @@ +// ===================================================================================================== +// VENDORED THIRD-PARTY FILE — DO NOT EDIT. Re-vendor from upstream instead (recipe below). +// +// Upstream project Eclipse Tahu — the Sparkplug B reference implementation +// Upstream repo https://github.com/eclipse-tahu/tahu +// Upstream path sparkplug_b/sparkplug_b.proto +// Pinned commit 5736e404889d4b95910613040a99ba79589ffb13 (master, 2023-11-06) +// Permalink https://raw.githubusercontent.com/eclipse-tahu/tahu/5736e404889d4b95910613040a99ba79589ffb13/sparkplug_b/sparkplug_b.proto +// git blob SHA-1 bf72ab5f09a333afabcb40fd45362ffbb0c8c5bd (matches the tree entry at that commit) +// content SHA-256 4432c5c483b7fb9732d0594c98a2e97dca5e517e39c5374a8b918d837f0b4a19 (8330 bytes) +// last modified 46f25e79f34234e6145d11108660dfd9133ae50d (2022-05-16, template_ref comment fix) +// License Eclipse Public License 2.0 (EPL-2.0) — https://www.eclipse.org/legal/epl-2.0/ +// Copyright (c) Cirrus Link Solutions and others. The upstream copyright + SPDX +// header is preserved verbatim as the first lines of the copied body below. +// +// Everything from line 38 down ("// * Copyright (c) 2015, 2018 Cirrus Link Solutions…") is a +// BYTE-FOR-BYTE copy of the upstream file. Only these header lines were added. To re-verify: +// +// curl -sSL -o /tmp/upstream.proto +// tail -n +38 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/Protos/sparkplug_b.proto > /tmp/vendored.proto +// diff /tmp/upstream.proto /tmp/vendored.proto && shasum -a 256 /tmp/vendored.proto +// +// WHY VENDORED rather than a NuGet package: SparkplugNet is the only .NET Sparkplug library and it is +// stale (1.3.10, 2024-07-02), transitively pins MQTTnet 4.3.6.1152 against this repo's MQTTnet 5.2.0, +// and ships net6.0/net8.0 only — no net10.0 TFM. So the schema is vendored and decoded directly with +// Google.Protobuf, over the same single MQTTnet-5 client the plain-MQTT path already uses. +// +// WHY THE proto2 FILE and not the sibling sparkplug_b_c_sharp.proto that upstream also ships: this is +// the NORMATIVE Sparkplug B schema (the C# sibling is a lossy proto3 restatement that drops explicit +// presence and rewrites the `extensions` ranges as `google.protobuf.Any`). protoc from Grpc.Tools +// generates valid C# from proto2, and the generated namespace is identical (Org.Eclipse.Tahu.Protobuf, +// PascalCased from `package org.eclipse.tahu.protobuf` — there is no `option csharp_namespace`). Keeping +// proto2 buys explicit presence — Has{Name,Alias,Seq,IsNull,Datatype} — which the decoder needs to tell +// "field absent" from "field present and zero" (an NBIRTH legitimately carries seq = 0, and a DATA +// metric legitimately omits `name` and carries only `alias`). +// ===================================================================================================== + +// * Copyright (c) 2015, 2018 Cirrus Link Solutions and others +// * +// * This program and the accompanying materials are made available under the +// * terms of the Eclipse Public License 2.0 which is available at +// * http://www.eclipse.org/legal/epl-2.0. +// * +// * SPDX-License-Identifier: EPL-2.0 +// * +// * Contributors: +// * Cirrus Link Solutions - initial implementation + +// +// To compile: +// cd client_libraries/java +// protoc --proto_path=../../ --java_out=src/main/java ../../sparkplug_b.proto +// + +syntax = "proto2"; + +package org.eclipse.tahu.protobuf; + +option java_package = "org.eclipse.tahu.protobuf"; +option java_outer_classname = "SparkplugBProto"; + +enum DataType { + // Indexes of Data Types + + // Unknown placeholder for future expansion. + Unknown = 0; + + // Basic Types + Int8 = 1; + Int16 = 2; + Int32 = 3; + Int64 = 4; + UInt8 = 5; + UInt16 = 6; + UInt32 = 7; + UInt64 = 8; + Float = 9; + Double = 10; + Boolean = 11; + String = 12; + DateTime = 13; + Text = 14; + + // Additional Metric Types + UUID = 15; + DataSet = 16; + Bytes = 17; + File = 18; + Template = 19; + + // Additional PropertyValue Types + PropertySet = 20; + PropertySetList = 21; + + // Array Types + Int8Array = 22; + Int16Array = 23; + Int32Array = 24; + Int64Array = 25; + UInt8Array = 26; + UInt16Array = 27; + UInt32Array = 28; + UInt64Array = 29; + FloatArray = 30; + DoubleArray = 31; + BooleanArray = 32; + StringArray = 33; + DateTimeArray = 34; +} + +message Payload { + + message Template { + + message Parameter { + optional string name = 1; + optional uint32 type = 2; + + oneof value { + uint32 int_value = 3; + uint64 long_value = 4; + float float_value = 5; + double double_value = 6; + bool boolean_value = 7; + string string_value = 8; + ParameterValueExtension extension_value = 9; + } + + message ParameterValueExtension { + extensions 1 to max; + } + } + + optional string version = 1; // The version of the Template to prevent mismatches + repeated Metric metrics = 2; // Each metric includes a name, datatype, and optionally a value + repeated Parameter parameters = 3; + optional string template_ref = 4; // MUST be a reference to a template definition if this is an instance (i.e. the name of the template definition) - MUST be omitted for template definitions + optional bool is_definition = 5; + extensions 6 to max; + } + + message DataSet { + + message DataSetValue { + + oneof value { + uint32 int_value = 1; + uint64 long_value = 2; + float float_value = 3; + double double_value = 4; + bool boolean_value = 5; + string string_value = 6; + DataSetValueExtension extension_value = 7; + } + + message DataSetValueExtension { + extensions 1 to max; + } + } + + message Row { + repeated DataSetValue elements = 1; + extensions 2 to max; // For third party extensions + } + + optional uint64 num_of_columns = 1; + repeated string columns = 2; + repeated uint32 types = 3; + repeated Row rows = 4; + extensions 5 to max; // For third party extensions + } + + message PropertyValue { + + optional uint32 type = 1; + optional bool is_null = 2; + + oneof value { + uint32 int_value = 3; + uint64 long_value = 4; + float float_value = 5; + double double_value = 6; + bool boolean_value = 7; + string string_value = 8; + PropertySet propertyset_value = 9; + PropertySetList propertysets_value = 10; // List of Property Values + PropertyValueExtension extension_value = 11; + } + + message PropertyValueExtension { + extensions 1 to max; + } + } + + message PropertySet { + repeated string keys = 1; // Names of the properties + repeated PropertyValue values = 2; + extensions 3 to max; + } + + message PropertySetList { + repeated PropertySet propertyset = 1; + extensions 2 to max; + } + + message MetaData { + // Bytes specific metadata + optional bool is_multi_part = 1; + + // General metadata + optional string content_type = 2; // Content/Media type + optional uint64 size = 3; // File size, String size, Multi-part size, etc + optional uint64 seq = 4; // Sequence number for multi-part messages + + // File metadata + optional string file_name = 5; // File name + optional string file_type = 6; // File type (i.e. xml, json, txt, cpp, etc) + optional string md5 = 7; // md5 of data + + // Catchalls and future expansion + optional string description = 8; // Could be anything such as json or xml of custom properties + extensions 9 to max; + } + + message Metric { + + optional string name = 1; // Metric name - should only be included on birth + optional uint64 alias = 2; // Metric alias - tied to name on birth and included in all later DATA messages + optional uint64 timestamp = 3; // Timestamp associated with data acquisition time + optional uint32 datatype = 4; // DataType of the metric/tag value + optional bool is_historical = 5; // If this is historical data and should not update real time tag + optional bool is_transient = 6; // Tells consuming clients such as MQTT Engine to not store this as a tag + optional bool is_null = 7; // If this is null - explicitly say so rather than using -1, false, etc for some datatypes. + optional MetaData metadata = 8; // Metadata for the payload + optional PropertySet properties = 9; + + oneof value { + uint32 int_value = 10; + uint64 long_value = 11; + float float_value = 12; + double double_value = 13; + bool boolean_value = 14; + string string_value = 15; + bytes bytes_value = 16; // Bytes, File + DataSet dataset_value = 17; + Template template_value = 18; + MetricValueExtension extension_value = 19; + } + + message MetricValueExtension { + extensions 1 to max; + } + } + + optional uint64 timestamp = 1; // Timestamp at message sending time + repeated Metric metrics = 2; // Repeated forever - no limit in Google Protobufs + optional uint64 seq = 3; // Sequence number + optional string uuid = 4; // UUID to track message type in terms of schema definitions + optional bytes body = 5; // To optionally bypass the whole definition above + extensions 6 to max; // For third party extensions +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj index 6c5a5492..1d50e2ee 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj @@ -8,4 +8,29 @@ + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugProtoCodegenTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugProtoCodegenTests.cs new file mode 100644 index 00000000..c3acc828 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugProtoCodegenTests.cs @@ -0,0 +1,100 @@ +using Google.Protobuf; +using Org.Eclipse.Tahu.Protobuf; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests; + +/// +/// Pins the vendored Eclipse Tahu sparkplug_b protobuf codegen: the generated types exist, +/// land in the namespace the rest of P2 is written against, and carry the Sparkplug-B field numbers +/// on the wire. Nothing here decodes a real payload — that is Task 16 (SparkplugCodec + +/// golden vectors); this suite exists so a broken/renamed/mangled vendored proto fails at the +/// codegen seam rather than as a mysterious decode failure three tasks later. +/// +public sealed class SparkplugProtoCodegenTests +{ + [Fact] + public void GeneratedPayload_RoundTrips() + { + var p = new Payload { Seq = 3 }; + p.Metrics.Add(new Payload.Types.Metric { Name = "Temperature", Alias = 5 }); + + var back = Payload.Parser.ParseFrom(p.ToByteArray()); + + back.Seq.ShouldBe(3ul); + back.Metrics[0].Name.ShouldBe("Temperature"); + back.Metrics[0].Alias.ShouldBe(5ul); + } + + /// + /// Tasks 16 / 20 / 25 name Org.Eclipse.Tahu.Protobuf explicitly, and the namespace is + /// derived (not declared) — protoc PascalCases the proto package because the vendored file + /// carries no option csharp_namespace. Editing the package line to "tidy" it would silently + /// rehome every generated type. + /// + [Fact] + public void GeneratedTypes_LiveInTheTahuPackageNamespace() + { + typeof(Payload).Namespace.ShouldBe("Org.Eclipse.Tahu.Protobuf"); + Payload.Descriptor.File.Package.ShouldBe("org.eclipse.tahu.protobuf"); + } + + /// + /// Field numbers are the wire contract with every Sparkplug edge node in the plant; a vendored + /// proto that was hand-edited or reconstructed rather than copied from upstream would most likely + /// drift here first. + /// + [Fact] + public void PayloadAndMetric_CarrySparkplugBFieldNumbers() + { + Payload.TimestampFieldNumber.ShouldBe(1); + Payload.MetricsFieldNumber.ShouldBe(2); + Payload.SeqFieldNumber.ShouldBe(3); + Payload.UuidFieldNumber.ShouldBe(4); + Payload.BodyFieldNumber.ShouldBe(5); + + Payload.Types.Metric.NameFieldNumber.ShouldBe(1); + Payload.Types.Metric.AliasFieldNumber.ShouldBe(2); + Payload.Types.Metric.TimestampFieldNumber.ShouldBe(3); + Payload.Types.Metric.DatatypeFieldNumber.ShouldBe(4); + Payload.Types.Metric.IsNullFieldNumber.ShouldBe(7); + } + + /// + /// Task 17's datatype map keys off these indices. They are Sparkplug-spec constants, not protobuf + /// tags, so they are worth pinning independently of the field numbers above. + /// + [Fact] + public void DataTypeEnum_MatchesSparkplugSpecIndices() + { + ((int)DataType.Int8).ShouldBe(1); + ((int)DataType.Int32).ShouldBe(3); + ((int)DataType.Uint64).ShouldBe(8); + ((int)DataType.Float).ShouldBe(9); + ((int)DataType.Double).ShouldBe(10); + ((int)DataType.Boolean).ShouldBe(11); + ((int)DataType.String).ShouldBe(12); + ((int)DataType.DateTime).ShouldBe(13); + ((int)DataType.DataSet).ShouldBe(16); + ((int)DataType.Template).ShouldBe(19); + ((int)DataType.Int32Array).ShouldBe(24); + } + + /// + /// protoc's C# generator RE-CASES enum member names: the proto's UInt64 becomes + /// Uint64 and UUID becomes Uuid (only the leading character of each + /// underscore-separated word survives as a capital). Task 17's datatype map has to spell them + /// the mangled way, so pin the mangling rather than letting it be rediscovered as a build error. + /// The wire-facing spelling is preserved on OriginalName, which is what + /// DataType.Parser-style JSON round-trips use. + /// + [Fact] + public void DataTypeEnum_CSharpNamesAreProtocMangled_NotTheProtoSpelling() + { + Enum.GetNames().ShouldContain("Uint64"); + Enum.GetNames().ShouldNotContain("UInt64"); + Enum.GetNames().ShouldContain("Uuid"); + Enum.GetNames().ShouldNotContain("UUID"); + } +}