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:
@@ -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<SparkplugDataType>()</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>_ => 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);
|
||||
}
|
||||
Reference in New Issue
Block a user