diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttEquipmentTagParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttEquipmentTagParser.cs new file mode 100644 index 00000000..24e33600 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttEquipmentTagParser.cs @@ -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; + +/// +/// Turns an authored equipment tag's opaque wire reference — its TagConfig JSON blob +/// — into a typed . Mirrors ModbusTagDefinitionFactory: a +/// leading { marks a TagConfig blob, enum fields are read STRICTLY, and the two entry +/// points carry deliberately different strictness contracts. +/// +/// +/// +/// is the runtime path. It never throws; anything +/// malformed returns so the driver surfaces +/// BadNodeIdUnknown rather than a misleading Good off a wrong-typed +/// default. +/// +/// +/// +/// +/// is the deploy-time path. It returns human-readable +/// warnings so a bad tag config surfaces at deploy instead of silently going dark at +/// runtime. +/// +/// +/// +/// +/// Sparkplug-descriptor parsing (groupId / edgeNodeId / deviceId / +/// metricName) is a deliberate P1 stub — the fields exist on +/// but are never populated here until the P2 tasks fill them. +/// +/// +public static class MqttEquipmentTagParser +{ + /// The JSONPath applied when a Json-format blob omits jsonPath: the document root. + private const string RootJsonPath = "$"; + + /// The MQTT wildcard characters — illegal in a tag's (concrete) subscription topic. + private static readonly char[] TopicWildcards = ['+', '#']; + + /// + /// Parses a driver wire into a typed definition. Returns + /// — never throws — for a null/blank reference, a reference that is not + /// a TagConfig blob (no leading {), unparseable JSON, a missing or blank topic, a + /// qos outside 0–2, or a present-but-invalid (typo'd) payloadFormat / + /// dataType. The produced definition's is + /// itself, which is the key published values are routed on. + /// + /// A wildcard topic (+ / #) is deliberately NOT rejected here: it is + /// ambiguous rather than unparseable, and is the operator-visible + /// surface for it. Rejecting it at runtime would turn an authoring mistake into a silent + /// BadNodeIdUnknown with no stated cause. + /// + /// + /// The tag's wire reference (its authored TagConfig JSON). + /// The parsed definition when this returns ; otherwise . + /// when is a valid MQTT tag config. + 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; } + } + + /// + /// Deploy-time inspection of an equipment-tag TagConfig blob. Returns human-readable + /// warnings for a structurally unparseable blob (which the runtime turns into a silent + /// BadNodeIdUnknown), for present-but-invalid payloadFormat / dataType + /// enum values, and for a wildcard tag topic (a tag bound to + / # 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. + /// + /// The equipment tag's TagConfig JSON. + /// The warnings; empty when clean. + public static IReadOnlyList Inspect(string reference) + { + var warnings = new List(); + 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(root, "payloadFormat"), + TagConfigJson.DescribeInvalidEnum(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; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinition.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinition.cs new file mode 100644 index 00000000..0d139be5 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinition.cs @@ -0,0 +1,65 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt; + +/// +/// The driver's internal per-tag descriptor — the parsed form of an authored equipment tag's +/// TagConfig JSON, produced by and looked up +/// through the shared exactly as Modbus does. See +/// docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md §5.2 / §5.3. +/// +/// The record carries both ingest shapes: the Plain-MQTT fields +/// (), populated in P1, and the Sparkplug B +/// descriptor fields (, , +/// , ), which are deliberate stubs in P1 +/// — always until the P2 tasks (15–26) teach the parser to read them. +/// A Sparkplug tag is resolved by the stable (group, node, device, metricName) tuple, +/// never by the per-birth metric alias. +/// +/// +/// +/// The definition's identity: the exact wire reference string the driver was handed +/// (the tag's authored TagConfig blob). Published values key the forward router on this, +/// so it must round-trip byte-for-byte. +/// +/// The concrete MQTT topic this tag subscribes to (Plain mode). +/// How the received payload is decoded (Plain mode). +/// +/// JSONPath selecting the value inside a payload. Defaults +/// to the document root ($) when the blob omits it; meaningless for +/// / . +/// +/// +/// The tag's declared value type. Explicit authoring is strongly preferred — payload-shape +/// inference is the brittle fallback (design §4). +/// +/// +/// The per-tag subscription QoS (0–2), or to inherit the driver-level +/// . +/// +/// +/// Whether the broker's retained message for seeds this tag's initial value +/// on subscribe (the OPC UA initial-data convention). Defaults to . +/// +/// P2 stub — the Sparkplug group id. Always in P1. +/// P2 stub — the Sparkplug edge-node id. Always in P1. +/// +/// P2 stub — the Sparkplug device id (absent for node-level metrics). Always +/// in P1. +/// +/// +/// P2 stub — the Sparkplug metric name, the tag's stable identity across rebirths. Always +/// in P1. +/// +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); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttEquipmentTagParserTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttEquipmentTagParserTests.cs new file mode 100644 index 00000000..037f7b2a --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttEquipmentTagParserTests.cs @@ -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; + +/// +/// Covers the two entry points of and their deliberately +/// different strictness contracts: TryParse is the runtime path (never throws, a +/// malformed blob is a hard reject that upstream turns into BadNodeIdUnknown) and +/// Inspect is the deploy-time path (human-readable warnings so a bad tag surfaces at +/// deploy instead of going dark at runtime). +/// +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(); +}