From b245f380b16f001812c0f119f57b06301c10406b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 21:16:02 -0400 Subject: [PATCH] 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 --- .../SparkplugDataType.cs | 139 +++++++++ .../Sparkplug/SparkplugTopic.cs | 291 ++++++++++++++++++ .../SparkplugDataTypeTests.cs | 127 ++++++++ .../SparkplugTopicTests.cs | 168 ++++++++++ 4 files changed, 725 insertions(+) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/SparkplugDataType.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugTopic.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugDataTypeTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugTopicTests.cs 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); +}