feat(mqtt): plain tag parser + strict-enum descriptor
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -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 0–2, 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 (15–26) 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 (0–2), 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 15–26) 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 0–2 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();
|
||||
}
|
||||
Reference in New Issue
Block a user