feat(mqtt): Sparkplug topic parse/format + datatype map

SparkplugTopic (Driver.Mqtt/Sparkplug/) parses/formats spBv1.0 topics for
every message type (NBIRTH/DBIRTH/NDATA/DDATA/NDEATH/DDEATH/NCMD/DCMD/STATE).
TryParse never throws -- it is fed every topic a live spBv1.0/{group}/#
subscription delivers -- and rejects device/node-scope mismatches and MQTT
wildcard chars in a segment. STATE is handled honestly rather than force-fit
into the {group}/{type}/{node} mould: it parses both the v3.0
spBv1.0/STATE/{hostId} form and the legacy no-prefix STATE/{hostId} form
(tolerated on receive only -- Format/FormatState always emit v3.0). Format
gives Task 20's NCMD/DCMD write path a builder instead of hand-concatenation.

SparkplugDataType is a `global using` alias for the vendored proto's
generated Org.Eclipse.Tahu.Protobuf.DataType, not a second hand-duplicated
enum -- Metric.Datatype is a raw wire uint32 (no enum-typed field forces a
second CLR type to exist), and SparkplugCodec (Task 16, landed concurrently)
already casts straight to the generated type. A duplicate enum would be the
same enum-drift hazard this repo already names systemic (CLAUDE.md's driver
enum-serialization bug) and would force every downstream task to cast
between two value-compatible-but-nominally-different enums. ToDriverDataType()
maps per design doc SS3.5: Int8/UInt8 widen to Int16/UInt16 (no 8-bit
DriverDataType member), Float/Double to Float32/Float64 (there is no
DriverDataType.Double), Text/UUID/Bytes/File to String, *Array variants to
their element type (IsSparkplugArray carries the array bit separately), and
DataSet/Template/PropertySet/PropertySetList/Unknown return null -- an
explicit "unsupported, caller must skip+warn" rather than a guessed String.

The completeness test enumerates the live generated DataType member set
(via the alias) and asserts every member is mapped or on the explicit
unsupported list, so a future Tahu proto change is caught automatically
instead of silently falling through a stale duplicate enum's default.
Falsifiability verified by hand for three defect shapes (each reverted after
observing RED): wrong Int8 widening, a dropped mapping falling through
undetected, and inverted node/device topic scoping.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 21:16:02 -04:00
parent c164ec3da1
commit b245f380b1
4 changed files with 725 additions and 0 deletions
@@ -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;
/// <summary>
/// Maps a Sparkplug B metric <see cref="SparkplugDataType"/> (the vendored Eclipse Tahu proto's
/// generated <c>Org.Eclipse.Tahu.Protobuf.DataType</c> enum — see the <c>global using</c> alias
/// above) to a driver-agnostic <see cref="DriverDataType"/>, per design doc §3.5.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why an alias, not a duplicate enum.</b> The obvious "clean seam" shape is a fresh
/// <c>Driver.Mqtt.Contracts</c>-owned enum with its own <c>ToDriverDataType()</c> — 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 <c>sparkplug_b.proto</c> 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
/// <c>Metric.datatype</c> wire field is a raw <c>uint32</c> (see <c>sparkplug_b.proto</c> line
/// 230 — <c>optional uint32 datatype = 4</c>), not the <c>DataType</c> enum type itself, so
/// <c>SparkplugCodec</c> (Task 16) already casts the decoded value straight to the generated
/// <c>Org.Eclipse.Tahu.Protobuf.DataType</c> (locally aliased there as <c>TahuDataType</c>).
/// 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.
/// </para>
/// <para>
/// Making <see cref="SparkplugDataType"/> a <c>global using</c> 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. <see cref="ToDriverDataType"/> below is
/// written as an extension on <see cref="SparkplugDataType"/> purely for call-site readability;
/// it is equally callable as <c>someDecodedDatatype.ToDriverDataType()</c> against a plain
/// <c>Org.Eclipse.Tahu.Protobuf.DataType</c> value with no cast, which is exactly the shape
/// <c>SparkplugCodec</c>'s decoded metrics hand back.
/// </para>
/// <para>
/// <b>Drift guard.</b> Because there is only one enum, <c>SparkplugDataTypeTests</c>'
/// completeness test (<c>ToDriverDataType_HandlesEveryGeneratedDataTypeMember_...</c>) can
/// enumerate <c>Enum.GetValues&lt;SparkplugDataType&gt;()</c> — 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.
/// </para>
/// <para>
/// Per design §3.5: <c>Int8</c>/<c>UInt8</c> widen to <see cref="DriverDataType.Int16"/> /
/// <see cref="DriverDataType.UInt16"/> (no OPC UA signed-byte distinction is needed and
/// <see cref="DriverDataType"/> has no 8-bit members at all); <c>Float</c>/<c>Double</c> map to
/// <see cref="DriverDataType.Float32"/> / <see cref="DriverDataType.Float64"/> — note there is
/// no <c>DriverDataType.Double</c> member, only <c>Float64</c>; <c>Text</c>/<c>UUID</c>
/// (generated as <c>Uuid</c> — protoc mangles the wire spelling, see
/// <c>SparkplugProtoCodegenTests.DataTypeEnum_CSharpNamesAreProtocMangled_NotTheProtoSpelling</c>)
/// /<c>Bytes</c>/<c>File</c> all fall back to <see cref="DriverDataType.String"/> (v1 base64/raw
/// fallback for Bytes/File, per design); every <c>*Array</c> variant maps to its scalar element
/// type (<see cref="IsSparkplugArray"/> carries the "and it's an array" bit separately, since
/// <see cref="DriverDataType"/> itself has no array concept — that lives at the OPC UA
/// ValueRank/ArrayDimensions layer the address-space builder owns). <c>DataSet</c>/
/// <c>Template</c>/<c>PropertySet</c>/<c>PropertySetList</c>/<c>Unknown</c> are unsupported in
/// v1 and map to <see langword="null"/> — deliberately, not a guessed
/// <see cref="DriverDataType.String"/>, so a caller has to make an explicit skip-or-warn
/// decision (unlike Galaxy's <c>DataTypeMap</c>, which silently defaults unknown codes to
/// <c>String</c> for legacy wire-compatibility reasons that do not apply here).
/// </para>
/// </remarks>
public static class SparkplugDataTypeExtensions
{
/// <summary>
/// Maps a Sparkplug metric datatype to the equivalent <see cref="DriverDataType"/>, or
/// <see langword="null"/> if the type is unsupported in v1 (<c>DataSet</c>, <c>Template</c>,
/// <c>PropertySet</c>, <c>PropertySetList</c>, <c>Unknown</c>). Callers must treat
/// <see langword="null"/> as an explicit "skip and warn", never coerce it to a guessed type.
/// </summary>
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,
};
/// <summary>
/// Whether <paramref name="dataType"/> is one of the 13 <c>*Array</c> variants. Combine with
/// <see cref="ToDriverDataType"/> (which already returns the *element* type for an array
/// variant) to build an OPC UA ValueRank=1 array node per design §3.5.
/// </summary>
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,
};
}
@@ -0,0 +1,291 @@
using System.Diagnostics.CodeAnalysis;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
/// <summary>
/// The Sparkplug B topic-namespace element that identifies a message's purpose — the third
/// segment of <c>spBv1.0/{group}/{type}/{node}[/{device}]</c>, or the second segment of the
/// differently-shaped <c>spBv1.0/STATE/{hostId}</c>. See design doc §3.1/§3.6 and Sparkplug B
/// v3.0 spec §6 (Topic Namespace Elements).
/// </summary>
public enum SparkplugMessageType
{
/// <summary>Node birth certificate — (re)publishes an edge node's full metric/alias set.</summary>
NBIRTH,
/// <summary>Device birth certificate — (re)publishes a device's full metric/alias set.</summary>
DBIRTH,
/// <summary>Node data — incremental metric updates owned by the edge node itself.</summary>
NDATA,
/// <summary>Device data — incremental metric updates for a device under the edge node.</summary>
DDATA,
/// <summary>Node death certificate (the edge node's MQTT Will) — the node has gone offline.</summary>
NDEATH,
/// <summary>Device death certificate — the device has gone offline (published by its edge node).</summary>
DDEATH,
/// <summary>Node command — a write/rebirth request addressed to the edge node.</summary>
NCMD,
/// <summary>Device command — a write request addressed to a device under the edge node.</summary>
DCMD,
/// <summary>
/// Primary-host online/offline state. Shaped differently from every other message type — see
/// the remarks on <see cref="SparkplugTopic"/> and <see cref="SparkplugTopic.HostId"/>.
/// </summary>
STATE,
}
/// <summary>
/// A parsed Sparkplug B MQTT topic. See design doc §3.1/§3.6 and Sparkplug B v3.0 spec §6.
/// </summary>
/// <param name="Type">The message's purpose.</param>
/// <param name="GroupId">
/// The Sparkplug group id. <see langword="null"/> for <see cref="SparkplugMessageType.STATE"/>,
/// always populated otherwise.
/// </param>
/// <param name="EdgeNodeId">
/// The Sparkplug edge-node id. <see langword="null"/> for <see cref="SparkplugMessageType.STATE"/>,
/// always populated otherwise.
/// </param>
/// <param name="DeviceId">
/// The Sparkplug device id, present only for the device-scoped message types
/// (<see cref="SparkplugMessageType.DBIRTH"/>/<see cref="SparkplugMessageType.DDATA"/>/
/// <see cref="SparkplugMessageType.DDEATH"/>/<see cref="SparkplugMessageType.DCMD"/>);
/// <see langword="null"/> for node-scoped messages and for <see cref="SparkplugMessageType.STATE"/>.
/// </param>
/// <param name="HostId">
/// The primary-host id, populated only for <see cref="SparkplugMessageType.STATE"/>; carries the
/// third topic segment of <c>spBv1.0/STATE/{hostId}</c> (or the second segment of the legacy
/// pre-3.0 <c>STATE/{hostId}</c> form). <see langword="null"/> for every other message type.
/// </param>
/// <remarks>
/// <para>
/// <b>Never throws on arbitrary input.</b> <see cref="TryParse"/> is the primitive everything
/// else is built on — it is fed every topic string the broker delivers under a
/// <c>spBv1.0/{groupId}/#</c> subscription, none of it validated ahead of time, so it returns
/// <see langword="false"/> for anything malformed rather than throwing. <see cref="Parse"/> is
/// a throwing convenience wrapper for call sites (tests, hand-built topics) that already know
/// the string is well-formed.
/// </para>
/// <para>
/// <b>STATE does not fit the <c>{group}/{type}/{node}</c> mould — handled honestly, not
/// force-fit.</b> The Sparkplug v3.0 spec shapes the primary-host state topic as
/// <c>spBv1.0/STATE/{hostId}</c>: 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 <c>STATE/{hostId}</c> with no <c>spBv1.0</c> namespace prefix. This parser
/// targets the v3.0 form and tolerates the legacy one on receive (design §3.1); <see cref="Format"/>
/// and <see cref="FormatState"/> only ever produce the v3.0 form. A parsed STATE topic carries
/// its host id in <see cref="HostId"/> and leaves <see cref="GroupId"/>/<see cref="EdgeNodeId"/>/
/// <see cref="DeviceId"/> <see langword="null"/> — every other message type is the mirror image
/// (<see cref="GroupId"/>/<see cref="EdgeNodeId"/> populated, <see cref="HostId"/> null).
/// </para>
/// <para>
/// Group/edge-node/device/host segments are treated as opaque identifiers: validated only for
/// non-emptiness and the absence of the MQTT wildcard characters <c>+</c>/<c>#</c> (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".
/// </para>
/// </remarks>
public sealed record SparkplugTopic(
SparkplugMessageType Type,
string? GroupId,
string? EdgeNodeId,
string? DeviceId,
string? HostId)
{
private const string Namespace = "spBv1.0";
/// <summary>
/// Attempts to parse <paramref name="topic"/> as a Sparkplug B topic. Never throws — returns
/// <see langword="false"/> (and a <see langword="null"/> <paramref name="result"/>) for
/// anything that is not a well-formed Sparkplug topic, including <see langword="null"/>/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.
/// </summary>
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;
}
/// <summary>
/// Parses <paramref name="topic"/>, throwing <see cref="FormatException"/> if it is not a
/// well-formed Sparkplug B topic. See <see cref="TryParse"/> for the non-throwing form.
/// </summary>
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.");
}
/// <summary>
/// Builds a Sparkplug B topic string for a non-STATE message — e.g.
/// <c>Format("Plant1", SparkplugMessageType.NCMD, "EdgeA")</c> for the Task 20 write path, so
/// it does not have to hand-concatenate segments itself.
/// </summary>
/// <exception cref="ArgumentException">
/// <paramref name="groupId"/>/<paramref name="edgeNodeId"/> is null/empty, or
/// <paramref name="type"/> is <see cref="SparkplugMessageType.STATE"/> (use
/// <see cref="FormatState"/> instead — STATE has no group/edge-node/device segments).
/// </exception>
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}";
}
/// <summary>Builds the v3.0 STATE topic string <c>spBv1.0/STATE/{hostId}</c>.</summary>
public static string FormatState(string hostId)
{
ArgumentException.ThrowIfNullOrEmpty(hostId);
return $"{Namespace}/STATE/{hostId}";
}
/// <summary>
/// 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).
/// </summary>
/// <exception cref="InvalidOperationException">
/// A required field for this instance's <see cref="Type"/> is <see langword="null"/> — i.e.
/// this instance was not built by <see cref="TryParse"/>/<see cref="Parse"/> and violates the
/// invariants documented on the type.
/// </exception>
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;
}
@@ -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;
/// <summary>
/// <see cref="SparkplugDataTypeExtensions.ToDriverDataType"/> / <see cref="SparkplugDataTypeExtensions.IsSparkplugArray"/>
/// coverage (Task 17), per design doc §3.5. <see cref="ToDriverDataType_HandlesEveryGeneratedDataTypeMember_MappedOrExplicitlyUnsupported"/>
/// is the drift guard: because <c>SparkplugDataType</c> is an alias for the vendored proto's
/// generated enum (not a hand-duplicated copy — see the remarks on
/// <see cref="SparkplugDataTypeExtensions"/>), enumerating it enumerates the live generated member
/// set, so a future Eclipse Tahu proto change that adds/removes a <c>DataType</c> member is caught
/// here automatically rather than needing a second enum kept manually in sync.
/// </summary>
public sealed class SparkplugDataTypeTests
{
/// <summary>Scalar (non-array) datatypes, per the design §3.5 table.</summary>
[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);
/// <summary>
/// <c>*Array</c> variants map to their scalar element type; <see cref="SparkplugDataTypeExtensions.IsSparkplugArray"/>
/// carries the "and it's an array" bit, since <see cref="DriverDataType"/> has no array concept
/// of its own (that's an OPC UA ValueRank/ArrayDimensions concern owned further up the stack).
/// </summary>
[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();
/// <summary>
/// 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 <see langword="null"/>,
/// never a guessed <see cref="DriverDataType.String"/>.
/// </summary>
[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();
/// <summary>
/// The drift guard (see class remarks). Enumerates the live generated <c>DataType</c> member
/// set (via the <c>SparkplugDataType</c> alias) and asserts every member is either mapped to a
/// <see cref="DriverDataType"/> or is one of the five documented-unsupported members — nothing
/// silently falls through the map's <c>_ =&gt; null</c> default undetected.
/// </summary>
[Fact]
public void ToDriverDataType_HandlesEveryGeneratedDataTypeMember_MappedOrExplicitlyUnsupported()
{
var unsupported = new HashSet<SparkplugDataType>
{
SparkplugDataType.Unknown,
SparkplugDataType.DataSet,
SparkplugDataType.Template,
SparkplugDataType.PropertySet,
SparkplugDataType.PropertySetList,
};
var allMembers = Enum.GetValues<SparkplugDataType>();
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.");
}
}
}
}
@@ -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;
/// <summary>
/// <see cref="SparkplugTopic"/> parse/format coverage (Task 17). <see cref="SparkplugTopic.TryParse"/>
/// is fed every topic string a live <c>spBv1.0/{group}/#</c> subscription delivers, so a large
/// share of this suite is "garbage in, <see langword="false"/> out, never a throw" — see
/// <see cref="TryParse_NeverThrows_ForArbitraryInput"/>.
/// </summary>
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<FormatException>(() => 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<ArgumentException>(() => 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);
}