feat(mqtt): plain tag parser + strict-enum descriptor

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 14:07:11 -04:00
parent a5a8710af5
commit e3fea6e409
3 changed files with 372 additions and 0 deletions
@@ -0,0 +1,164 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
/// <summary>
/// Turns an authored equipment tag's opaque wire <c>reference</c> — its <c>TagConfig</c> JSON blob
/// — into a typed <see cref="MqttTagDefinition"/>. Mirrors <c>ModbusTagDefinitionFactory</c>: a
/// leading <c>{</c> marks a TagConfig blob, enum fields are read STRICTLY, and the two entry
/// points carry deliberately different strictness contracts.
/// <list type="bullet">
/// <item>
/// <description>
/// <see cref="TryParse"/> is the <b>runtime</b> path. It never throws; anything
/// malformed returns <see langword="false"/> so the driver surfaces
/// <c>BadNodeIdUnknown</c> rather than a misleading <c>Good</c> off a wrong-typed
/// default.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="Inspect"/> is the <b>deploy-time</b> path. It returns human-readable
/// warnings so a bad tag config surfaces at deploy instead of silently going dark at
/// runtime.
/// </description>
/// </item>
/// </list>
/// <para>
/// Sparkplug-descriptor parsing (<c>groupId</c> / <c>edgeNodeId</c> / <c>deviceId</c> /
/// <c>metricName</c>) is a deliberate P1 stub — the fields exist on
/// <see cref="MqttTagDefinition"/> but are never populated here until the P2 tasks fill them.
/// </para>
/// </summary>
public static class MqttEquipmentTagParser
{
/// <summary>The JSONPath applied when a Json-format blob omits <c>jsonPath</c>: the document root.</summary>
private const string RootJsonPath = "$";
/// <summary>The MQTT wildcard characters — illegal in a <em>tag's</em> (concrete) subscription topic.</summary>
private static readonly char[] TopicWildcards = ['+', '#'];
/// <summary>
/// Parses a driver wire <paramref name="reference"/> into a typed definition. Returns
/// <see langword="false"/> — never throws — for a null/blank reference, a reference that is not
/// a TagConfig blob (no leading <c>{</c>), unparseable JSON, a missing or blank <c>topic</c>, a
/// <c>qos</c> outside 02, or a present-but-invalid (typo'd) <c>payloadFormat</c> /
/// <c>dataType</c>. The produced definition's <see cref="MqttTagDefinition.Name"/> is
/// <paramref name="reference"/> itself, which is the key published values are routed on.
/// <para>
/// A <b>wildcard</b> topic (<c>+</c> / <c>#</c>) is deliberately NOT rejected here: it is
/// ambiguous rather than unparseable, and <see cref="Inspect"/> is the operator-visible
/// surface for it. Rejecting it at runtime would turn an authoring mistake into a silent
/// <c>BadNodeIdUnknown</c> with no stated cause.
/// </para>
/// </summary>
/// <param name="reference">The tag's wire reference (its authored <c>TagConfig</c> JSON).</param>
/// <param name="def">The parsed definition when this returns <see langword="true"/>; otherwise <see langword="null"/>.</param>
/// <returns><see langword="true"/> when <paramref name="reference"/> is a valid MQTT tag config.</returns>
public static bool TryParse(string reference, [NotNullWhen(true)] out MqttTagDefinition? def)
{
def = null;
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return false;
try
{
using var doc = JsonDocument.Parse(reference);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object) return false;
// topic is the whole point of a Plain-mode tag — without one there is nothing to subscribe
// to, so an absent/blank value is a hard reject rather than a defaulted empty subscription.
var topic = ReadString(root, "topic");
if (string.IsNullOrWhiteSpace(topic)) return false;
// Strict enum reads: a present-but-invalid (typo'd) value rejects the whole tag
// (→ BadNodeIdUnknown) instead of silently defaulting to a wrong-typed Good.
if (!TagConfigJson.TryReadEnumStrict(root, "payloadFormat", MqttPayloadFormat.Json, out var payloadFormat))
return false;
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", DriverDataType.String, out var dataType))
return false;
// qos is an MQTT protocol field with exactly three legal values; anything else would be
// rejected by the broker at SUBSCRIBE time, so reject it here where the cause is visible.
var qos = ReadOptionalInt(root, "qos");
if (qos is { } q && q is < 0 or > 2) return false;
var jsonPath = ReadString(root, "jsonPath");
if (string.IsNullOrEmpty(jsonPath)) jsonPath = RootJsonPath;
def = new MqttTagDefinition(
Name: reference,
Topic: topic,
PayloadFormat: payloadFormat,
JsonPath: jsonPath,
DataType: dataType,
Qos: qos,
RetainSeed: ReadBoolOrDefault(root, "retainSeed", defaultValue: true));
return true;
}
catch (JsonException) { return false; }
catch (FormatException) { return false; }
catch (InvalidOperationException) { return false; }
}
/// <summary>
/// Deploy-time inspection of an equipment-tag <c>TagConfig</c> blob. Returns human-readable
/// warnings for a structurally unparseable blob (which the runtime turns into a silent
/// <c>BadNodeIdUnknown</c>), for present-but-invalid <c>payloadFormat</c> / <c>dataType</c>
/// enum values, and for a <b>wildcard</b> tag topic (a tag bound to <c>+</c> / <c>#</c> would
/// receive values from many topics — ambiguous, and almost never what the operator meant).
/// Empty when the blob is clean or is not a TagConfig object at all. Never throws.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
try
{
using var doc = JsonDocument.Parse(reference);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
warnings.Add("Mqtt TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
return warnings;
}
foreach (var w in new[]
{
TagConfigJson.DescribeInvalidEnum<MqttPayloadFormat>(root, "payloadFormat"),
TagConfigJson.DescribeInvalidEnum<DriverDataType>(root, "dataType"),
})
{
if (w is not null) warnings.Add(w);
}
var topic = ReadString(root, "topic");
if (topic.IndexOfAny(TopicWildcards) >= 0)
{
warnings.Add(
$"topic '{topic}' contains an MQTT wildcard (+ or #); a tag's topic must be concrete, " +
"or the tag will be fed by every matching topic.");
}
}
catch (JsonException)
{
warnings.Add("Mqtt TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
}
return warnings;
}
private static string ReadString(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
? e.GetString() ?? ""
: "";
private static int? ReadOptionalInt(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number
&& e.TryGetInt32(out var v) ? v : null;
private static bool ReadBoolOrDefault(JsonElement o, string name, bool defaultValue)
=> o.TryGetProperty(name, out var e) && e.ValueKind is JsonValueKind.True or JsonValueKind.False
? e.GetBoolean()
: defaultValue;
}
@@ -0,0 +1,65 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
/// <summary>
/// The driver's internal per-tag descriptor — the parsed form of an authored equipment tag's
/// <c>TagConfig</c> JSON, produced by <see cref="MqttEquipmentTagParser.TryParse"/> and looked up
/// through the shared <see cref="EquipmentTagRefResolver{TDef}"/> exactly as Modbus does. See
/// <c>docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md</c> §5.2 / §5.3.
/// <para>
/// The record carries <b>both</b> ingest shapes: the Plain-MQTT fields
/// (<see cref="Topic"/> … <see cref="RetainSeed"/>), populated in P1, and the Sparkplug B
/// descriptor fields (<see cref="GroupId"/>, <see cref="EdgeNodeId"/>,
/// <see cref="DeviceId"/>, <see cref="MetricName"/>), which are deliberate <b>stubs</b> in P1
/// — always <see langword="null"/> until the P2 tasks (1526) teach the parser to read them.
/// A Sparkplug tag is resolved by the stable <c>(group, node, device, metricName)</c> tuple,
/// never by the per-birth metric alias.
/// </para>
/// </summary>
/// <param name="Name">
/// The definition's identity: the exact wire <c>reference</c> string the driver was handed
/// (the tag's authored <c>TagConfig</c> blob). Published values key the forward router on this,
/// so it must round-trip byte-for-byte.
/// </param>
/// <param name="Topic">The concrete MQTT topic this tag subscribes to (Plain mode).</param>
/// <param name="PayloadFormat">How the received payload is decoded (Plain mode).</param>
/// <param name="JsonPath">
/// JSONPath selecting the value inside a <see cref="MqttPayloadFormat.Json"/> payload. Defaults
/// to the document root (<c>$</c>) when the blob omits it; meaningless for
/// <see cref="MqttPayloadFormat.Raw"/> / <see cref="MqttPayloadFormat.Scalar"/>.
/// </param>
/// <param name="DataType">
/// The tag's declared value type. Explicit authoring is strongly preferred — payload-shape
/// inference is the brittle fallback (design §4).
/// </param>
/// <param name="Qos">
/// The per-tag subscription QoS (02), or <see langword="null"/> to inherit the driver-level
/// <see cref="MqttPlainOptions.DefaultQos"/>.
/// </param>
/// <param name="RetainSeed">
/// Whether the broker's retained message for <see cref="Topic"/> seeds this tag's initial value
/// on subscribe (the OPC UA initial-data convention). Defaults to <see langword="true"/>.
/// </param>
/// <param name="GroupId">P2 stub — the Sparkplug group id. Always <see langword="null"/> in P1.</param>
/// <param name="EdgeNodeId">P2 stub — the Sparkplug edge-node id. Always <see langword="null"/> in P1.</param>
/// <param name="DeviceId">
/// P2 stub — the Sparkplug device id (absent for node-level metrics). Always
/// <see langword="null"/> in P1.
/// </param>
/// <param name="MetricName">
/// P2 stub — the Sparkplug metric name, the tag's stable identity across rebirths. Always
/// <see langword="null"/> in P1.
/// </param>
public sealed record MqttTagDefinition(
string Name,
string Topic,
MqttPayloadFormat PayloadFormat,
string JsonPath,
DriverDataType DataType,
int? Qos,
bool RetainSeed,
string? GroupId = null,
string? EdgeNodeId = null,
string? DeviceId = null,
string? MetricName = null);
@@ -0,0 +1,143 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// Covers the two entry points of <see cref="MqttEquipmentTagParser"/> and their deliberately
/// different strictness contracts: <c>TryParse</c> is the runtime path (never throws, a
/// malformed blob is a hard reject that upstream turns into <c>BadNodeIdUnknown</c>) and
/// <c>Inspect</c> is the deploy-time path (human-readable warnings so a bad tag surfaces at
/// deploy instead of going dark at runtime).
/// </summary>
public sealed class MqttEquipmentTagParserTests
{
[Fact]
public void TryParse_PlainJsonBlob_PopulatesTopicAndPath()
{
// NOTE: the plan's sample blob wrote "dataType":"Double"; the authoritative type is
// DriverDataType (design §6.1), whose 64-bit float member is Float64. "Double" is
// therefore a typo the strict read rejects — pinned by TryParse_TypoedDataType_RejectsStrict.
const string r = """{"topic":"factory/oven/temp","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64","qos":1}""";
MqttEquipmentTagParser.TryParse(r, out var def).ShouldBeTrue();
def!.Topic.ShouldBe("factory/oven/temp");
def.PayloadFormat.ShouldBe(MqttPayloadFormat.Json);
def.JsonPath.ShouldBe("$.value");
def.DataType.ShouldBe(DriverDataType.Float64);
def.Qos.ShouldBe(1);
def.Name.ShouldBe(r); // the def Name == reference string (forward-router key)
}
[Fact]
public void TryParse_TypoedPayloadFormat_RejectsStrict()
=> MqttEquipmentTagParser.TryParse(
"""{"topic":"a/b","payloadFormat":"Jason","dataType":"Float64"}""", out _).ShouldBeFalse();
[Fact]
public void TryParse_TypoedDataType_RejectsStrict()
=> MqttEquipmentTagParser.TryParse(
"""{"topic":"a/b","payloadFormat":"Json","dataType":"Double"}""", out _).ShouldBeFalse();
[Fact]
public void Inspect_WildcardTopic_ReturnsWarning()
{
// The blob is otherwise clean (payloadFormat parses, dataType absent), so the wildcard check
// is the ONLY thing that can produce a warning here — the assertion cannot pass vacuously.
var warnings = MqttEquipmentTagParser.Inspect("""{"topic":"a/+/c","payloadFormat":"Raw"}""");
warnings.ShouldNotBeEmpty();
warnings.ShouldHaveSingleItem().ShouldContain("wildcard", Case.Insensitive);
}
[Theory]
[InlineData("a/#")]
[InlineData("+/b/c")]
[InlineData("a/+/c")]
public void Inspect_EachWildcardForm_Warns(string topic)
=> MqttEquipmentTagParser.Inspect($$"""{"topic":"{{topic}}","payloadFormat":"Raw"}""").ShouldNotBeEmpty();
[Fact]
public void TryParse_WildcardTopic_StillParses_WarningIsDeployTimeOnly()
{
// A wildcard tag topic is ambiguous, not unparseable: the deploy-time Inspect pass is the
// designed surface for it. Rejecting it at runtime would turn an authoring mistake into a
// silent BadNodeIdUnknown with no operator-visible cause.
MqttEquipmentTagParser.TryParse("""{"topic":"a/+/c","payloadFormat":"Raw"}""", out var def).ShouldBeTrue();
def!.Topic.ShouldBe("a/+/c");
}
[Fact]
public void TryParse_RawFormat_AppliesDefaults()
{
const string r = """{"topic":"factory/oven/blob","payloadFormat":"Raw"}""";
MqttEquipmentTagParser.TryParse(r, out var def).ShouldBeTrue();
def!.PayloadFormat.ShouldBe(MqttPayloadFormat.Raw);
def.JsonPath.ShouldBe("$"); // absent ⇒ the root default
def.Qos.ShouldBeNull(); // absent ⇒ the driver-level DefaultQos wins
def.RetainSeed.ShouldBeTrue(); // absent ⇒ seed from the retained message
def.DataType.ShouldBe(DriverDataType.String); // absent ⇒ the documented fallback
}
[Fact]
public void TryParse_ExplicitRetainSeedFalse_IsHonoured()
{
MqttEquipmentTagParser.TryParse(
"""{"topic":"a/b","payloadFormat":"Raw","retainSeed":false}""", out var def).ShouldBeTrue();
def!.RetainSeed.ShouldBeFalse();
}
[Fact]
public void TryParse_PlainBlob_LeavesSparkplugDescriptorFieldsNull()
{
// P2 (Tasks 1526) fills these; a plain-mode blob must leave them unset so a future
// mode discriminator cannot mistake a plain tag for a Sparkplug one.
MqttEquipmentTagParser.TryParse(
"""{"topic":"a/b","payloadFormat":"Raw"}""", out var def).ShouldBeTrue();
def!.GroupId.ShouldBeNull();
def.EdgeNodeId.ShouldBeNull();
def.DeviceId.ShouldBeNull();
def.MetricName.ShouldBeNull();
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("factory/oven/temp")] // a bare reference, not a TagConfig blob (no leading '{')
[InlineData("[1,2,3]")] // valid JSON, wrong root kind
[InlineData("{ not json at all")] // unparseable — must not throw
[InlineData("""{"payloadFormat":"Raw"}""")] // no topic ⇒ nothing to subscribe to
[InlineData("""{"topic":"","payloadFormat":"Raw"}""")] // blank topic
[InlineData("""{"topic":"a/b","qos":5}""")] // QoS outside 02 is an illegal subscription
public void TryParse_MalformedReference_ReturnsFalseAndNeverThrows(string? reference)
=> MqttEquipmentTagParser.TryParse(reference!, out _).ShouldBeFalse();
[Fact]
public void Inspect_CleanBlob_ReturnsEmpty()
=> MqttEquipmentTagParser.Inspect(
"""{"topic":"a/b","payloadFormat":"Json","jsonPath":"$.v","dataType":"Float64"}""").ShouldBeEmpty();
[Fact]
public void Inspect_TypoedEnums_WarnsPerField()
{
var warnings = MqttEquipmentTagParser.Inspect(
"""{"topic":"a/b","payloadFormat":"Jason","dataType":"Double"}""");
warnings.Count.ShouldBe(2);
warnings.ShouldContain(w => w.Contains("payloadFormat", StringComparison.Ordinal));
warnings.ShouldContain(w => w.Contains("dataType", StringComparison.Ordinal));
}
[Fact]
public void Inspect_UnparseableBlob_Warns()
=> MqttEquipmentTagParser.Inspect("{ not json at all").ShouldNotBeEmpty();
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("factory/oven/temp")]
[InlineData("[1,2,3]")] // no leading '{' ⇒ not a TagConfig blob at all (mirrors Modbus)
public void Inspect_NotATagConfigBlob_ReturnsEmpty(string? reference)
=> MqttEquipmentTagParser.Inspect(reference!).ShouldBeEmpty();
}