feat(mqtt): vendor Tahu sparkplug_b.proto + Grpc.Tools codegen

Vendors the NORMATIVE (proto2) Eclipse Tahu sparkplug_b.proto and compiles it
in .Contracts with build-time Grpc.Tools, message-only (GrpcServices="None") --
Sparkplug rides MQTT, there is no gRPC service, so no Grpc.Core.Api is pulled in.

Provenance rides in the file header: repo, path, pinned commit
5736e404889d4b95910613040a99ba79589ffb13, permalink, git blob SHA-1
bf72ab5f..., content SHA-256 4432c5c4..., EPL-2.0, and a re-verify recipe. The
body below line 38 is byte-for-byte upstream; the blob SHA-1 matches the tree
entry at that commit, so the copy is provably genuine and not reconstructed.

Chose the proto2 file over upstream's sibling sparkplug_b_c_sharp.proto: the
sibling is a lossy proto3 restatement that drops explicit presence, and protoc
generates valid C# from proto2 into the identical namespace
(Org.Eclipse.Tahu.Protobuf, PascalCased from the package -- no
csharp_namespace option). Presence matters downstream: an NBIRTH legitimately
carries seq = 0 and a DATA metric legitimately omits `name`, so Has{Seq,Name,
Alias,IsNull,Datatype} is the difference between "absent" and "present, zero".

.Contracts stays transport-free: Google.Protobuf is a serialization dependency
with a framework-only graph, Grpc.Tools is PrivateAssets=all, and the resolved
graph is exactly {Google.Protobuf, Grpc.Tools, Core.Abstractions} -- no MQTTnet.

Codegen tests pin the round-trip, the namespace, the Sparkplug field numbers,
the DataType spec indices, and protoc's enum-name mangling (UInt64 -> Uint64,
UUID -> Uuid) that Task 17's datatype map has to spell correctly.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 20:54:23 -04:00
parent 92ba120964
commit 1ae26675ac
3 changed files with 386 additions and 0 deletions
@@ -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 <permalink above> -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
}
@@ -8,4 +8,29 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/> <ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup> </ItemGroup>
<!-- Sparkplug B (P2): the vendored Eclipse Tahu schema is compiled here, in the one assembly all
four MQTT projects reference. Message-only codegen (GrpcServices="None") — Sparkplug rides MQTT,
there is no gRPC service, so no Grpc.Core.Api runtime reference is needed (Commons, this repo's
other locally-compiled proto, takes one only because it generates service stubs).
Google.Protobuf is a SERIALIZATION dependency, not a transport one, so .Contracts stays
transport-free: it still has no MQTTnet reference (dropped in Task 1) and Google.Protobuf's own
graph is framework-only. Grpc.Tools is build-time (PrivateAssets=all) and flows to no consumer.
Build-platform note (inherited, not new): Grpc.Tools' bundled linux_arm64 protoc segfaults
(exit 139) under Apple-Silicon Docker, which is why docker-dev/Dockerfile pins its build stage
to --platform=linux/amd64. That pin already exists for the Commons proto; this project adds a
second .proto to the same already-pinned build, not a new constraint. -->
<ItemGroup>
<PackageReference Include="Google.Protobuf"/>
<PackageReference Include="Grpc.Tools">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<Protobuf Include="Protos\sparkplug_b.proto" GrpcServices="None"/>
</ItemGroup>
</Project> </Project>
@@ -0,0 +1,100 @@
using Google.Protobuf;
using Org.Eclipse.Tahu.Protobuf;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// Pins the vendored Eclipse Tahu <c>sparkplug_b</c> 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 (<c>SparkplugCodec</c> +
/// 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.
/// </summary>
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);
}
/// <summary>
/// Tasks 16 / 20 / 25 name <c>Org.Eclipse.Tahu.Protobuf</c> explicitly, and the namespace is
/// derived (not declared) — protoc PascalCases the proto <c>package</c> because the vendored file
/// carries no <c>option csharp_namespace</c>. Editing the package line to "tidy" it would silently
/// rehome every generated type.
/// </summary>
[Fact]
public void GeneratedTypes_LiveInTheTahuPackageNamespace()
{
typeof(Payload).Namespace.ShouldBe("Org.Eclipse.Tahu.Protobuf");
Payload.Descriptor.File.Package.ShouldBe("org.eclipse.tahu.protobuf");
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// protoc's C# generator RE-CASES enum member names: the proto's <c>UInt64</c> becomes
/// <c>Uint64</c> and <c>UUID</c> becomes <c>Uuid</c> (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 <c>OriginalName</c>, which is what
/// <c>DataType.Parser</c>-style JSON round-trips use.
/// </summary>
[Fact]
public void DataTypeEnum_CSharpNamesAreProtocMangled_NotTheProtoSpelling()
{
Enum.GetNames<DataType>().ShouldContain("Uint64");
Enum.GetNames<DataType>().ShouldNotContain("UInt64");
Enum.GetNames<DataType>().ShouldContain("Uuid");
Enum.GetNames<DataType>().ShouldNotContain("UUID");
}
}