diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/SparkplugDataType.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/SparkplugDataType.cs
new file mode 100644
index 00000000..20bc98b9
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/SparkplugDataType.cs
@@ -0,0 +1,139 @@
+// `SparkplugDataType` is an ALIAS for the vendored proto's generated `Org.Eclipse.Tahu.Protobuf.DataType`
+// enum, not a second, hand-maintained enum. See the remarks on `SparkplugDataTypeExtensions` below for
+// the reasoning; the short version is that a duplicate enum is a drift hazard this repo has a documented
+// systemic bug class around (see CLAUDE.md "Driver enum-serialization bug"), and `Payload.Types.Metric`'s
+// wire-level `Datatype` field is a raw `uint32` anyway — nothing structurally forces a second CLR enum to
+// exist, so the lowest-risk shape is for `SparkplugDataType` to be the exact same type as the generated
+// one, not a value-compatible lookalike that some cast has to bridge.
+global using SparkplugDataType = Org.Eclipse.Tahu.Protobuf.DataType;
+
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
+
+///
+/// Maps a Sparkplug B metric (the vendored Eclipse Tahu proto's
+/// generated Org.Eclipse.Tahu.Protobuf.DataType enum — see the global using alias
+/// above) to a driver-agnostic , per design doc §3.5.
+///
+///
+///
+/// Why an alias, not a duplicate enum. The obvious "clean seam" shape is a fresh
+/// Driver.Mqtt.Contracts-owned enum with its own ToDriverDataType() — but that
+/// creates a second definition of the same 35-member vocabulary that has to be hand-kept in
+/// sync with whatever Eclipse Tahu's sparkplug_b.proto defines, which is exactly the
+/// enum-drift shape this repo already has a name for (CLAUDE.md's "Driver enum-serialization
+/// bug (AdminUI authoring)" — a systemic mismatch between two enums meant to describe the same
+/// thing). It also does not match how the decoder actually produces values: Sparkplug's
+/// Metric.datatype wire field is a raw uint32 (see sparkplug_b.proto line
+/// 230 — optional uint32 datatype = 4), not the DataType enum type itself, so
+/// SparkplugCodec (Task 16) already casts the decoded value straight to the generated
+/// Org.Eclipse.Tahu.Protobuf.DataType (locally aliased there as TahuDataType).
+/// A second, hand-duplicated enum would force every downstream consumer (Tasks 18/19/20/21) to
+/// cast between two value-compatible-but-nominally-different enums to call this extension
+/// method — extra surface for exactly zero benefit, since both "enums" would need to enumerate
+/// the identical 35 members in the identical order to stay castable.
+///
+///
+/// Making a global using alias for the generated type
+/// sidesteps all of that: there is no second definition to drift, by construction — the alias
+/// and the generated enum are the exact same CLR type. below is
+/// written as an extension on purely for call-site readability;
+/// it is equally callable as someDecodedDatatype.ToDriverDataType() against a plain
+/// Org.Eclipse.Tahu.Protobuf.DataType value with no cast, which is exactly the shape
+/// SparkplugCodec's decoded metrics hand back.
+///
+///
+/// Drift guard. Because there is only one enum, SparkplugDataTypeTests'
+/// completeness test (ToDriverDataType_HandlesEveryGeneratedDataTypeMember_...) can
+/// enumerate Enum.GetValues<SparkplugDataType>() — which, being the alias, is
+/// literally the live generated member set — and assert every member is either mapped or on
+/// the explicit unsupported list below. If Eclipse Tahu's proto ever gains a member, that test
+/// picks it up automatically and fails until this map makes an explicit decision about it,
+/// rather than the new member silently falling through a duplicate-enum's stale default.
+///
+///
+/// Per design §3.5: Int8/UInt8 widen to /
+/// (no OPC UA signed-byte distinction is needed and
+/// has no 8-bit members at all); Float/Double map to
+/// / — note there is
+/// no DriverDataType.Double member, only Float64; Text/UUID
+/// (generated as Uuid — protoc mangles the wire spelling, see
+/// SparkplugProtoCodegenTests.DataTypeEnum_CSharpNamesAreProtocMangled_NotTheProtoSpelling)
+/// /Bytes/File all fall back to (v1 base64/raw
+/// fallback for Bytes/File, per design); every *Array variant maps to its scalar element
+/// type ( carries the "and it's an array" bit separately, since
+/// itself has no array concept — that lives at the OPC UA
+/// ValueRank/ArrayDimensions layer the address-space builder owns). DataSet/
+/// Template/PropertySet/PropertySetList/Unknown are unsupported in
+/// v1 and map to — deliberately, not a guessed
+/// , so a caller has to make an explicit skip-or-warn
+/// decision (unlike Galaxy's DataTypeMap, which silently defaults unknown codes to
+/// String for legacy wire-compatibility reasons that do not apply here).
+///
+///
+public static class SparkplugDataTypeExtensions
+{
+ ///
+ /// Maps a Sparkplug metric datatype to the equivalent , or
+ /// if the type is unsupported in v1 (DataSet, Template,
+ /// PropertySet, PropertySetList, Unknown). Callers must treat
+ /// as an explicit "skip and warn", never coerce it to a guessed type.
+ ///
+ public static DriverDataType? ToDriverDataType(this SparkplugDataType dataType) => dataType switch
+ {
+ SparkplugDataType.Int8 => DriverDataType.Int16,
+ SparkplugDataType.Int16 => DriverDataType.Int16,
+ SparkplugDataType.Int32 => DriverDataType.Int32,
+ SparkplugDataType.Int64 => DriverDataType.Int64,
+ SparkplugDataType.Uint8 => DriverDataType.UInt16,
+ SparkplugDataType.Uint16 => DriverDataType.UInt16,
+ SparkplugDataType.Uint32 => DriverDataType.UInt32,
+ SparkplugDataType.Uint64 => DriverDataType.UInt64,
+ SparkplugDataType.Float => DriverDataType.Float32,
+ SparkplugDataType.Double => DriverDataType.Float64,
+ SparkplugDataType.Boolean => DriverDataType.Boolean,
+ SparkplugDataType.String => DriverDataType.String,
+ SparkplugDataType.DateTime => DriverDataType.DateTime,
+ SparkplugDataType.Text => DriverDataType.String,
+ SparkplugDataType.Uuid => DriverDataType.String,
+ SparkplugDataType.Bytes => DriverDataType.String,
+ SparkplugDataType.File => DriverDataType.String,
+
+ SparkplugDataType.Int8Array => DriverDataType.Int16,
+ SparkplugDataType.Int16Array => DriverDataType.Int16,
+ SparkplugDataType.Int32Array => DriverDataType.Int32,
+ SparkplugDataType.Int64Array => DriverDataType.Int64,
+ SparkplugDataType.Uint8Array => DriverDataType.UInt16,
+ SparkplugDataType.Uint16Array => DriverDataType.UInt16,
+ SparkplugDataType.Uint32Array => DriverDataType.UInt32,
+ SparkplugDataType.Uint64Array => DriverDataType.UInt64,
+ SparkplugDataType.FloatArray => DriverDataType.Float32,
+ SparkplugDataType.DoubleArray => DriverDataType.Float64,
+ SparkplugDataType.BooleanArray => DriverDataType.Boolean,
+ SparkplugDataType.StringArray => DriverDataType.String,
+ SparkplugDataType.DateTimeArray => DriverDataType.DateTime,
+
+ // Unsupported v1 (design §3.5): DataSet/Template are deferred scope; PropertySet/
+ // PropertySetList are PropertyValue-only metadata types that never legitimately appear as a
+ // Metric's own datatype; Unknown is the proto's explicit "placeholder for future expansion"
+ // (index 0). All five fall through here deliberately rather than being listed with a fake
+ // mapping.
+ _ => null,
+ };
+
+ ///
+ /// Whether is one of the 13 *Array variants. Combine with
+ /// (which already returns the *element* type for an array
+ /// variant) to build an OPC UA ValueRank=1 array node per design §3.5.
+ ///
+ public static bool IsSparkplugArray(this SparkplugDataType dataType) => dataType switch
+ {
+ SparkplugDataType.Int8Array or SparkplugDataType.Int16Array or SparkplugDataType.Int32Array
+ or SparkplugDataType.Int64Array or SparkplugDataType.Uint8Array or SparkplugDataType.Uint16Array
+ or SparkplugDataType.Uint32Array or SparkplugDataType.Uint64Array or SparkplugDataType.FloatArray
+ or SparkplugDataType.DoubleArray or SparkplugDataType.BooleanArray or SparkplugDataType.StringArray
+ or SparkplugDataType.DateTimeArray => true,
+ _ => false,
+ };
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugTopic.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugTopic.cs
new file mode 100644
index 00000000..6f65be5b
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugTopic.cs
@@ -0,0 +1,291 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
+
+///
+/// The Sparkplug B topic-namespace element that identifies a message's purpose — the third
+/// segment of spBv1.0/{group}/{type}/{node}[/{device}], or the second segment of the
+/// differently-shaped spBv1.0/STATE/{hostId}. See design doc §3.1/§3.6 and Sparkplug B
+/// v3.0 spec §6 (Topic Namespace Elements).
+///
+public enum SparkplugMessageType
+{
+ /// Node birth certificate — (re)publishes an edge node's full metric/alias set.
+ NBIRTH,
+
+ /// Device birth certificate — (re)publishes a device's full metric/alias set.
+ DBIRTH,
+
+ /// Node data — incremental metric updates owned by the edge node itself.
+ NDATA,
+
+ /// Device data — incremental metric updates for a device under the edge node.
+ DDATA,
+
+ /// Node death certificate (the edge node's MQTT Will) — the node has gone offline.
+ NDEATH,
+
+ /// Device death certificate — the device has gone offline (published by its edge node).
+ DDEATH,
+
+ /// Node command — a write/rebirth request addressed to the edge node.
+ NCMD,
+
+ /// Device command — a write request addressed to a device under the edge node.
+ DCMD,
+
+ ///
+ /// Primary-host online/offline state. Shaped differently from every other message type — see
+ /// the remarks on and .
+ ///
+ STATE,
+}
+
+///
+/// A parsed Sparkplug B MQTT topic. See design doc §3.1/§3.6 and Sparkplug B v3.0 spec §6.
+///
+/// The message's purpose.
+///
+/// The Sparkplug group id. for ,
+/// always populated otherwise.
+///
+///
+/// The Sparkplug edge-node id. for ,
+/// always populated otherwise.
+///
+///
+/// The Sparkplug device id, present only for the device-scoped message types
+/// (//
+/// /);
+/// for node-scoped messages and for .
+///
+///
+/// The primary-host id, populated only for ; carries the
+/// third topic segment of spBv1.0/STATE/{hostId} (or the second segment of the legacy
+/// pre-3.0 STATE/{hostId} form). for every other message type.
+///
+///
+///
+/// Never throws on arbitrary input. is the primitive everything
+/// else is built on — it is fed every topic string the broker delivers under a
+/// spBv1.0/{groupId}/# subscription, none of it validated ahead of time, so it returns
+/// for anything malformed rather than throwing. is
+/// a throwing convenience wrapper for call sites (tests, hand-built topics) that already know
+/// the string is well-formed.
+///
+///
+/// STATE does not fit the {group}/{type}/{node} mould — handled honestly, not
+/// force-fit. The Sparkplug v3.0 spec shapes the primary-host state topic as
+/// spBv1.0/STATE/{hostId}: the message-type segment sits where a group id would
+/// otherwise be, and there is no edge-node or device segment at all. Pre-3.0 peers additionally
+/// published a bare STATE/{hostId} with no spBv1.0 namespace prefix. This parser
+/// targets the v3.0 form and tolerates the legacy one on receive (design §3.1);
+/// and only ever produce the v3.0 form. A parsed STATE topic carries
+/// its host id in and leaves //
+/// — every other message type is the mirror image
+/// (/ populated, null).
+///
+///
+/// Group/edge-node/device/host segments are treated as opaque identifiers: validated only for
+/// non-emptiness and the absence of the MQTT wildcard characters +/# (a broker
+/// never delivers a PUBLISH on a topic containing either, so a topic string that does is
+/// malformed, not a legitimate id worth preserving). No further character-set restriction is
+/// applied — Sparkplug does not constrain id charsets beyond "not a topic-level separator".
+///
+///
+public sealed record SparkplugTopic(
+ SparkplugMessageType Type,
+ string? GroupId,
+ string? EdgeNodeId,
+ string? DeviceId,
+ string? HostId)
+{
+ private const string Namespace = "spBv1.0";
+
+ ///
+ /// Attempts to parse as a Sparkplug B topic. Never throws — returns
+ /// (and a ) for
+ /// anything that is not a well-formed Sparkplug topic, including /empty
+ /// input, wrong namespace, an unrecognised message-type segment, a device-scoped message
+ /// missing its device segment (or vice versa), or a segment containing an MQTT wildcard.
+ ///
+ public static bool TryParse(string? topic, [NotNullWhen(true)] out SparkplugTopic? result)
+ {
+ result = null;
+ if (string.IsNullOrEmpty(topic))
+ {
+ return false;
+ }
+
+ var segments = topic.Split('/');
+
+ // Legacy pre-3.0 STATE form: `STATE/{hostId}`, no `spBv1.0` namespace prefix.
+ if (segments.Length == 2 && segments[0] == "STATE")
+ {
+ return TryBuildState(segments[1], out result);
+ }
+
+ if (segments.Length < 2 || segments[0] != Namespace)
+ {
+ return false;
+ }
+
+ // v3.0 STATE form: `spBv1.0/STATE/{hostId}`.
+ if (segments[1] == "STATE")
+ {
+ return segments.Length == 3 && TryBuildState(segments[2], out result);
+ }
+
+ // Every other message type: `spBv1.0/{group}/{type}/{node}[/{device}]`.
+ if (segments.Length is not (4 or 5))
+ {
+ return false;
+ }
+
+ if (!TryParseMessageType(segments[2], out var type))
+ {
+ return false;
+ }
+
+ var groupId = segments[1];
+ var edgeNodeId = segments[3];
+ if (!IsValidSegment(groupId) || !IsValidSegment(edgeNodeId))
+ {
+ return false;
+ }
+
+ var expectsDevice = IsDeviceScoped(type);
+ var hasDeviceSegment = segments.Length == 5;
+ if (expectsDevice != hasDeviceSegment)
+ {
+ return false;
+ }
+
+ string? deviceId = null;
+ if (hasDeviceSegment)
+ {
+ deviceId = segments[4];
+ if (!IsValidSegment(deviceId))
+ {
+ return false;
+ }
+ }
+
+ result = new SparkplugTopic(type, groupId, edgeNodeId, deviceId, null);
+ return true;
+ }
+
+ ///
+ /// Parses , throwing if it is not a
+ /// well-formed Sparkplug B topic. See for the non-throwing form.
+ ///
+ public static SparkplugTopic Parse(string? topic)
+ {
+ if (TryParse(topic, out var result))
+ {
+ return result;
+ }
+
+ throw new FormatException($"'{topic}' is not a valid Sparkplug B topic.");
+ }
+
+ ///
+ /// Builds a Sparkplug B topic string for a non-STATE message — e.g.
+ /// Format("Plant1", SparkplugMessageType.NCMD, "EdgeA") for the Task 20 write path, so
+ /// it does not have to hand-concatenate segments itself.
+ ///
+ ///
+ /// / is null/empty, or
+ /// is (use
+ /// instead — STATE has no group/edge-node/device segments).
+ ///
+ public static string Format(string groupId, SparkplugMessageType type, string edgeNodeId, string? deviceId = null)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(groupId);
+ ArgumentException.ThrowIfNullOrEmpty(edgeNodeId);
+ if (type == SparkplugMessageType.STATE)
+ {
+ throw new ArgumentException("Use FormatState to build a STATE topic.", nameof(type));
+ }
+
+ return deviceId is null
+ ? $"{Namespace}/{groupId}/{type}/{edgeNodeId}"
+ : $"{Namespace}/{groupId}/{type}/{edgeNodeId}/{deviceId}";
+ }
+
+ /// Builds the v3.0 STATE topic string spBv1.0/STATE/{hostId}.
+ public static string FormatState(string hostId)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(hostId);
+ return $"{Namespace}/STATE/{hostId}";
+ }
+
+ ///
+ /// Formats this instance back to its topic string (always the v3.0 STATE form for STATE
+ /// topics, even if this instance was parsed from the legacy no-prefix form).
+ ///
+ ///
+ /// A required field for this instance's is — i.e.
+ /// this instance was not built by / and violates the
+ /// invariants documented on the type.
+ ///
+ public string ToTopicString() => Type == SparkplugMessageType.STATE
+ ? FormatState(HostId ?? throw new InvalidOperationException("STATE topic is missing HostId."))
+ : Format(
+ GroupId ?? throw new InvalidOperationException("Non-STATE topic is missing GroupId."),
+ Type,
+ EdgeNodeId ?? throw new InvalidOperationException("Non-STATE topic is missing EdgeNodeId."),
+ DeviceId);
+
+ private static bool TryBuildState(string hostId, out SparkplugTopic? result)
+ {
+ result = null;
+ if (!IsValidSegment(hostId))
+ {
+ return false;
+ }
+
+ result = new SparkplugTopic(SparkplugMessageType.STATE, null, null, null, hostId);
+ return true;
+ }
+
+ private static bool TryParseMessageType(string segment, out SparkplugMessageType type)
+ {
+ switch (segment)
+ {
+ case nameof(SparkplugMessageType.NBIRTH):
+ type = SparkplugMessageType.NBIRTH;
+ return true;
+ case nameof(SparkplugMessageType.DBIRTH):
+ type = SparkplugMessageType.DBIRTH;
+ return true;
+ case nameof(SparkplugMessageType.NDATA):
+ type = SparkplugMessageType.NDATA;
+ return true;
+ case nameof(SparkplugMessageType.DDATA):
+ type = SparkplugMessageType.DDATA;
+ return true;
+ case nameof(SparkplugMessageType.NDEATH):
+ type = SparkplugMessageType.NDEATH;
+ return true;
+ case nameof(SparkplugMessageType.DDEATH):
+ type = SparkplugMessageType.DDEATH;
+ return true;
+ case nameof(SparkplugMessageType.NCMD):
+ type = SparkplugMessageType.NCMD;
+ return true;
+ case nameof(SparkplugMessageType.DCMD):
+ type = SparkplugMessageType.DCMD;
+ return true;
+ default:
+ type = default;
+ return false;
+ }
+ }
+
+ private static bool IsDeviceScoped(SparkplugMessageType type) => type is
+ SparkplugMessageType.DBIRTH or SparkplugMessageType.DDATA or SparkplugMessageType.DDEATH or SparkplugMessageType.DCMD;
+
+ private static bool IsValidSegment(string segment) =>
+ segment.Length > 0 && segment.IndexOf('+') < 0 && segment.IndexOf('#') < 0;
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugDataTypeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugDataTypeTests.cs
new file mode 100644
index 00000000..475a1e59
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugDataTypeTests.cs
@@ -0,0 +1,127 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using Shouldly;
+using Xunit;
+
+// See SparkplugTopicTests.cs for why this alias is redeclared locally in every consuming project
+// rather than inherited: `SparkplugDataType` is a `global using` alias for the generated
+// `Org.Eclipse.Tahu.Protobuf.DataType` (declared in Contracts/SparkplugDataType.cs), and `global using`
+// scope does not cross a ProjectReference.
+using SparkplugDataType = Org.Eclipse.Tahu.Protobuf.DataType;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
+
+///
+/// /
+/// coverage (Task 17), per design doc §3.5.
+/// is the drift guard: because SparkplugDataType is an alias for the vendored proto's
+/// generated enum (not a hand-duplicated copy — see the remarks on
+/// ), enumerating it enumerates the live generated member
+/// set, so a future Eclipse Tahu proto change that adds/removes a DataType member is caught
+/// here automatically rather than needing a second enum kept manually in sync.
+///
+public sealed class SparkplugDataTypeTests
+{
+ /// Scalar (non-array) datatypes, per the design §3.5 table.
+ [Theory]
+ [InlineData(SparkplugDataType.Int8, DriverDataType.Int16)] // widened: no 8-bit DriverDataType member
+ [InlineData(SparkplugDataType.Int16, DriverDataType.Int16)]
+ [InlineData(SparkplugDataType.Int32, DriverDataType.Int32)]
+ [InlineData(SparkplugDataType.Int64, DriverDataType.Int64)]
+ [InlineData(SparkplugDataType.Uint8, DriverDataType.UInt16)] // widened
+ [InlineData(SparkplugDataType.Uint16, DriverDataType.UInt16)]
+ [InlineData(SparkplugDataType.Uint32, DriverDataType.UInt32)]
+ [InlineData(SparkplugDataType.Uint64, DriverDataType.UInt64)]
+ [InlineData(SparkplugDataType.Float, DriverDataType.Float32)]
+ [InlineData(SparkplugDataType.Double, DriverDataType.Float64)] // NOT DriverDataType.Double — no such member
+ [InlineData(SparkplugDataType.Boolean, DriverDataType.Boolean)]
+ [InlineData(SparkplugDataType.String, DriverDataType.String)]
+ [InlineData(SparkplugDataType.DateTime, DriverDataType.DateTime)]
+ [InlineData(SparkplugDataType.Text, DriverDataType.String)]
+ [InlineData(SparkplugDataType.Uuid, DriverDataType.String)] // generated name for the proto's `UUID`
+ [InlineData(SparkplugDataType.Bytes, DriverDataType.String)] // v1 base64/raw fallback
+ [InlineData(SparkplugDataType.File, DriverDataType.String)] // v1 base64/raw fallback
+ public void ToDriverDataType_MapsScalarTypes(SparkplugDataType s, DriverDataType d)
+ => s.ToDriverDataType().ShouldBe(d);
+
+ ///
+ /// *Array variants map to their scalar element type;
+ /// carries the "and it's an array" bit, since has no array concept
+ /// of its own (that's an OPC UA ValueRank/ArrayDimensions concern owned further up the stack).
+ ///
+ [Theory]
+ [InlineData(SparkplugDataType.Int8Array, DriverDataType.Int16)]
+ [InlineData(SparkplugDataType.Int16Array, DriverDataType.Int16)]
+ [InlineData(SparkplugDataType.Int32Array, DriverDataType.Int32)]
+ [InlineData(SparkplugDataType.Int64Array, DriverDataType.Int64)]
+ [InlineData(SparkplugDataType.Uint8Array, DriverDataType.UInt16)]
+ [InlineData(SparkplugDataType.Uint16Array, DriverDataType.UInt16)]
+ [InlineData(SparkplugDataType.Uint32Array, DriverDataType.UInt32)]
+ [InlineData(SparkplugDataType.Uint64Array, DriverDataType.UInt64)]
+ [InlineData(SparkplugDataType.FloatArray, DriverDataType.Float32)]
+ [InlineData(SparkplugDataType.DoubleArray, DriverDataType.Float64)]
+ [InlineData(SparkplugDataType.BooleanArray, DriverDataType.Boolean)]
+ [InlineData(SparkplugDataType.StringArray, DriverDataType.String)]
+ [InlineData(SparkplugDataType.DateTimeArray, DriverDataType.DateTime)]
+ public void ToDriverDataType_ArrayVariants_MapToElementType_AndFlagIsArray(SparkplugDataType s, DriverDataType d)
+ {
+ s.ToDriverDataType().ShouldBe(d);
+ s.IsSparkplugArray().ShouldBeTrue();
+ }
+
+ [Theory]
+ [InlineData(SparkplugDataType.Int32)]
+ [InlineData(SparkplugDataType.Boolean)]
+ [InlineData(SparkplugDataType.String)]
+ [InlineData(SparkplugDataType.Bytes)]
+ public void IsSparkplugArray_ScalarTypes_ReturnsFalse(SparkplugDataType s)
+ => s.IsSparkplugArray().ShouldBeFalse();
+
+ ///
+ /// Design §3.5: "DataSet, Template → unsupported v1". Extended here to the two PropertyValue-only
+ /// variants and the proto's explicit placeholder — all five must come back ,
+ /// never a guessed .
+ ///
+ [Theory]
+ [InlineData(SparkplugDataType.Unknown)]
+ [InlineData(SparkplugDataType.DataSet)]
+ [InlineData(SparkplugDataType.Template)]
+ [InlineData(SparkplugDataType.PropertySet)]
+ [InlineData(SparkplugDataType.PropertySetList)]
+ public void ToDriverDataType_UnsupportedTypes_ReturnsNull_NotAGuess(SparkplugDataType s)
+ => s.ToDriverDataType().ShouldBeNull();
+
+ ///
+ /// The drift guard (see class remarks). Enumerates the live generated DataType member
+ /// set (via the SparkplugDataType alias) and asserts every member is either mapped to a
+ /// or is one of the five documented-unsupported members — nothing
+ /// silently falls through the map's _ => null default undetected.
+ ///
+ [Fact]
+ public void ToDriverDataType_HandlesEveryGeneratedDataTypeMember_MappedOrExplicitlyUnsupported()
+ {
+ var unsupported = new HashSet
+ {
+ SparkplugDataType.Unknown,
+ SparkplugDataType.DataSet,
+ SparkplugDataType.Template,
+ SparkplugDataType.PropertySet,
+ SparkplugDataType.PropertySetList,
+ };
+
+ var allMembers = Enum.GetValues();
+ allMembers.Length.ShouldBe(35, "this pins the generated member count Task 15 vendored; a changed count means the proto changed and every assertion below needs re-auditing.");
+
+ foreach (var value in allMembers)
+ {
+ var mapped = value.ToDriverDataType();
+ if (unsupported.Contains(value))
+ {
+ mapped.ShouldBeNull($"{value} is documented unsupported (design §3.5) and must map to null, not a guessed DriverDataType.");
+ }
+ else
+ {
+ mapped.ShouldNotBeNull($"{value} has no ToDriverDataType() mapping — every generated DataType member must be mapped or explicitly listed as unsupported.");
+ }
+ }
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugTopicTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugTopicTests.cs
new file mode 100644
index 00000000..acad75aa
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugTopicTests.cs
@@ -0,0 +1,168 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
+using Shouldly;
+using Xunit;
+
+// Local alias so this file can spell the mapping table the way design doc §3.5 does
+// (`SparkplugDataType.Int8`, etc.) — `SparkplugDataType` is a `global using` alias for the generated
+// `Org.Eclipse.Tahu.Protobuf.DataType` declared in `SparkplugDataType.cs` (Contracts project); that
+// `global using` is scoped to the project that declares it and does not cross the ProjectReference
+// into this test project, so it is redeclared here, locally, rather than duplicating the enum itself.
+// See the remarks on `SparkplugDataTypeExtensions` for the full alias-vs-duplicate rationale.
+using SparkplugDataType = Org.Eclipse.Tahu.Protobuf.DataType;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
+
+///
+/// parse/format coverage (Task 17).
+/// is fed every topic string a live spBv1.0/{group}/# subscription delivers, so a large
+/// share of this suite is "garbage in, out, never a throw" — see
+/// .
+///
+public sealed class SparkplugTopicTests
+{
+ [Fact]
+ public void Parse_DeviceData_ExtractsAllSegments()
+ {
+ var t = SparkplugTopic.Parse("spBv1.0/Plant1/DDATA/EdgeA/Filler1");
+
+ t.GroupId.ShouldBe("Plant1");
+ t.Type.ShouldBe(SparkplugMessageType.DDATA);
+ t.EdgeNodeId.ShouldBe("EdgeA");
+ t.DeviceId.ShouldBe("Filler1");
+ t.HostId.ShouldBeNull();
+ }
+
+ [Fact]
+ public void Parse_NodeData_HasNoDeviceSegment()
+ {
+ var t = SparkplugTopic.Parse("spBv1.0/Plant1/NDATA/EdgeA");
+
+ t.GroupId.ShouldBe("Plant1");
+ t.Type.ShouldBe(SparkplugMessageType.NDATA);
+ t.EdgeNodeId.ShouldBe("EdgeA");
+ t.DeviceId.ShouldBeNull();
+ t.HostId.ShouldBeNull();
+ }
+
+ [Theory]
+ [InlineData("spBv1.0/Plant1/NBIRTH/EdgeA", SparkplugMessageType.NBIRTH)]
+ [InlineData("spBv1.0/Plant1/NDATA/EdgeA", SparkplugMessageType.NDATA)]
+ [InlineData("spBv1.0/Plant1/NDEATH/EdgeA", SparkplugMessageType.NDEATH)]
+ [InlineData("spBv1.0/Plant1/NCMD/EdgeA", SparkplugMessageType.NCMD)]
+ public void Parse_NodeScopedTypes_Recognised(string topic, SparkplugMessageType expected)
+ => SparkplugTopic.Parse(topic).Type.ShouldBe(expected);
+
+ [Theory]
+ [InlineData("spBv1.0/Plant1/DBIRTH/EdgeA/Filler1", SparkplugMessageType.DBIRTH)]
+ [InlineData("spBv1.0/Plant1/DDATA/EdgeA/Filler1", SparkplugMessageType.DDATA)]
+ [InlineData("spBv1.0/Plant1/DDEATH/EdgeA/Filler1", SparkplugMessageType.DDEATH)]
+ [InlineData("spBv1.0/Plant1/DCMD/EdgeA/Filler1", SparkplugMessageType.DCMD)]
+ public void Parse_DeviceScopedTypes_Recognised(string topic, SparkplugMessageType expected)
+ => SparkplugTopic.Parse(topic).Type.ShouldBe(expected);
+
+ [Fact]
+ public void Parse_V3StateForm_ExtractsHostId()
+ {
+ var t = SparkplugTopic.Parse("spBv1.0/STATE/otopcua-host-1");
+
+ t.Type.ShouldBe(SparkplugMessageType.STATE);
+ t.HostId.ShouldBe("otopcua-host-1");
+ t.GroupId.ShouldBeNull();
+ t.EdgeNodeId.ShouldBeNull();
+ t.DeviceId.ShouldBeNull();
+ }
+
+ [Fact]
+ public void Parse_LegacyStateForm_ExtractsHostId()
+ {
+ // Pre-3.0 peers publish `STATE/{hostId}` with no `spBv1.0` namespace prefix — tolerated on
+ // receive per design §3.1, even though this driver only ever *emits* the v3.0 form.
+ var t = SparkplugTopic.Parse("STATE/otopcua-host-1");
+
+ t.Type.ShouldBe(SparkplugMessageType.STATE);
+ t.HostId.ShouldBe("otopcua-host-1");
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData("not/a/sparkplug/topic/at/all/too/many/segments")]
+ [InlineData("spBv1.0")]
+ [InlineData("spBv1.0/")]
+ [InlineData("spBv1.0/Plant1")]
+ [InlineData("spBv1.0/Plant1/BOGUS/EdgeA")]
+ [InlineData("spBv1.0/Plant1/NDATA")]
+ [InlineData("spBv1.0/Plant1/NDATA/EdgeA/UnexpectedDevice")]
+ [InlineData("spBv1.0/Plant1/DDATA/EdgeA")]
+ [InlineData("spBv1.0/Plant1/DDATA/EdgeA/Filler1/Extra")]
+ [InlineData("wrong-namespace/Plant1/NDATA/EdgeA")]
+ [InlineData("spBv1.0//NDATA/EdgeA")]
+ [InlineData("spBv1.0/Plant1/NDATA/")]
+ [InlineData("spBv1.0/Plant1/DDATA/EdgeA/")]
+ [InlineData("spBv1.0/Plant+/NDATA/EdgeA")]
+ [InlineData("spBv1.0/Plant1/NDATA/Edge#A")]
+ [InlineData("spBv1.0/STATE")]
+ [InlineData("spBv1.0/STATE/")]
+ [InlineData("spBv1.0/STATE/Host/Extra")]
+ [InlineData("STATE")]
+ [InlineData("STATE/")]
+ public void TryParse_NeverThrows_ForArbitraryInput(string? topic)
+ {
+ var ex = Record.Exception(() => SparkplugTopic.TryParse(topic, out var result));
+ ex.ShouldBeNull();
+ SparkplugTopic.TryParse(topic, out var result2).ShouldBeFalse();
+ result2.ShouldBeNull();
+ }
+
+ [Fact]
+ public void Parse_InvalidTopic_ThrowsFormatException()
+ => Should.Throw(() => SparkplugTopic.Parse("not-a-sparkplug-topic"));
+
+ [Fact]
+ public void Format_NodeScoped_BuildsExpectedTopic()
+ => SparkplugTopic.Format("Plant1", SparkplugMessageType.NCMD, "EdgeA")
+ .ShouldBe("spBv1.0/Plant1/NCMD/EdgeA");
+
+ [Fact]
+ public void Format_DeviceScoped_BuildsExpectedTopic()
+ => SparkplugTopic.Format("Plant1", SparkplugMessageType.DCMD, "EdgeA", "Filler1")
+ .ShouldBe("spBv1.0/Plant1/DCMD/EdgeA/Filler1");
+
+ [Fact]
+ public void Format_State_Throws_UseFormatStateInstead()
+ => Should.Throw(() => SparkplugTopic.Format("Plant1", SparkplugMessageType.STATE, "EdgeA"));
+
+ [Fact]
+ public void FormatState_BuildsV3Topic()
+ => SparkplugTopic.FormatState("otopcua-host-1").ShouldBe("spBv1.0/STATE/otopcua-host-1");
+
+ [Theory]
+ [InlineData("spBv1.0/Plant1/NBIRTH/EdgeA")]
+ [InlineData("spBv1.0/Plant1/NDATA/EdgeA")]
+ [InlineData("spBv1.0/Plant1/NDEATH/EdgeA")]
+ [InlineData("spBv1.0/Plant1/NCMD/EdgeA")]
+ [InlineData("spBv1.0/Plant1/DBIRTH/EdgeA/Filler1")]
+ [InlineData("spBv1.0/Plant1/DDATA/EdgeA/Filler1")]
+ [InlineData("spBv1.0/Plant1/DDEATH/EdgeA/Filler1")]
+ [InlineData("spBv1.0/Plant1/DCMD/EdgeA/Filler1")]
+ [InlineData("spBv1.0/STATE/otopcua-host-1")]
+ public void ToTopicString_RoundTrips(string topic)
+ => SparkplugTopic.Parse(topic).ToTopicString().ShouldBe(topic);
+
+ [Fact]
+ public void ToTopicString_LegacyStateTopic_NormalisesToV3Form()
+ // Parsed from the legacy no-prefix form, but formatting always emits the v3.0 shape.
+ => SparkplugTopic.Parse("STATE/otopcua-host-1").ToTopicString().ShouldBe("spBv1.0/STATE/otopcua-host-1");
+
+ // ---- The plan's illustrative datatype-map smoke theory (spelled with the protoc-mangled member
+ // names: the generated enum has `Uint8`, not `UInt8` — see SparkplugDataTypeTests for the
+ // full table). Kept here alongside the topic tests per the Task 17 plan's original grouping;
+ // SparkplugDataTypeTests.cs carries the exhaustive coverage + the completeness/drift guard. ----
+ [Theory]
+ [InlineData(SparkplugDataType.Int8, DriverDataType.Int16)]
+ [InlineData(SparkplugDataType.Uint8, DriverDataType.UInt16)]
+ [InlineData(SparkplugDataType.Float, DriverDataType.Float32)]
+ public void ToDriverDataType_MapsAndWidens(SparkplugDataType s, DriverDataType d)
+ => s.ToDriverDataType().ShouldBe(d);
+}