From d677ac7ad3aa5344487d83e509fb1dfb45288ff4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 13:45:21 -0400 Subject: [PATCH 01/43] feat(mqtt): validate MQTTnet-5/net10 restore under central pinning (spike, P1 gate) Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- Directory.Packages.props | 9 +++++++++ ZB.MOM.WW.OtOpcUa.slnx | 1 + .../ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj | 14 ++++++++++++++ 3 files changed, 24 insertions(+) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj diff --git a/Directory.Packages.props b/Directory.Packages.props index a30e3e2d..dfdd4f62 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -75,6 +75,15 @@ + + diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index b6647bd2..020a95af 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -42,6 +42,7 @@ + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj new file mode 100644 index 00000000..3645fe89 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj @@ -0,0 +1,14 @@ + + + net10.0 + enable + enable + true + + + + + + From f22db5d8015924c42a569c168a1a23403b068d59 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 13:53:06 -0400 Subject: [PATCH 02/43] feat(mqtt): Contracts options DTO + enums (name-serialized) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 1 of the MQTT/Sparkplug B driver plan: MqttMode, MqttPayloadFormat, MqttProtocolVersion enums + MqttDriverOptions (broker conn + mode + nullable Sparkplug/Plain sub-options) per design doc §5.1. Removes the Task 0 temporary MQTTnet PackageReference from .Contracts (transport-free; references only Core.Abstractions). MQTTnet stays pinned in Directory.Packages.props for the .Driver project (Task 3). Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- ZB.MOM.WW.OtOpcUa.slnx | 1 + .../MqttDriverOptions.cs | 152 ++++++++++++++++++ .../MqttMode.cs | 14 ++ .../MqttPayloadFormat.cs | 17 ++ .../MqttProtocolVersion.cs | 14 ++ ...OM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj | 5 +- .../MqttDriverOptionsTests.cs | 39 +++++ ...ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests.csproj | 26 +++ 8 files changed, 264 insertions(+), 4 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttDriverOptions.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttMode.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttPayloadFormat.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttProtocolVersion.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverOptionsTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests.csproj diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 020a95af..6e6ccab6 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -105,6 +105,7 @@ + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttDriverOptions.cs new file mode 100644 index 00000000..4fe77477 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttDriverOptions.cs @@ -0,0 +1,152 @@ +using System.ComponentModel.DataAnnotations; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt; + +/// +/// MQTT / Sparkplug B driver configuration. Bound from DriverConfig JSON at +/// driver-host registration time. Models the settings documented in +/// docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md §5.1. +/// +/// +/// A record (not a plain class) so a future secret-resolution seam can produce a +/// credential-resolved copy with a with expression — mirrors +/// OpcUaClientDriverOptions. selects the ingest shape; +/// and are nullable sub-objects — only the one +/// matching is populated. +/// +public sealed record MqttDriverOptions +{ + /// Broker hostname or IP address. + public string Host { get; init; } = "localhost"; + + /// Broker TCP port. + [Range(1, 65535)] + public int Port { get; init; } = 8883; + + /// + /// MQTT client identifier sent at CONNECT. Leave unset to let the driver generate one + /// (a stable per-instance id is recommended so broker-side ACLs / session state persist + /// across reconnects). + /// + public string? ClientId { get; init; } + + /// + /// When true, connect over TLS. Default true — this driver never ships an + /// anonymous/plaintext-by-default posture; a deployment must opt into false for an + /// on-prem/dev broker with no TLS listener. + /// + public bool UseTls { get; init; } = true; + + /// + /// When true, accept any self-signed / untrusted broker certificate. Dev/on-prem + /// escape hatch only — mirrors the ServerHistorian TLS knobs. Must stay + /// false in production so MITM attacks against the broker connection fail closed. + /// + public bool AllowUntrustedServerCertificate { get; init; } = false; + + /// + /// PEM CA file pinning the broker's TLS chain. null/empty uses the OS trust + /// store. + /// + public string? CaCertificatePath { get; init; } + + /// Username for broker authentication. null connects without a username. + public string? Username { get; init; } + + /// + /// Password for broker authentication. Blank in committed JSON — supply via env, never + /// commit or log. + /// + public string? Password { get; init; } = ""; + + /// MQTT protocol version to negotiate at CONNECT. + public MqttProtocolVersion ProtocolVersion { get; init; } = MqttProtocolVersion.V500; + + /// Whether to request a clean session (v3.1.1) / clean start (v5.0) at CONNECT. + public bool CleanSession { get; init; } = true; + + /// Keep-alive interval sent at CONNECT. + [Range(1, int.MaxValue)] + public int KeepAliveSeconds { get; init; } = 30; + + /// Bounded connect deadline (see design §8) — the driver never hangs past this. + [Range(1, int.MaxValue)] + public int ConnectTimeoutSeconds { get; init; } = 15; + + /// Initial reconnect backoff after a connection drop. + [Range(1, int.MaxValue)] + public int ReconnectMinBackoffSeconds { get; init; } = 1; + + /// Cap on the exponential reconnect backoff. + [Range(1, int.MaxValue)] + public int ReconnectMaxBackoffSeconds { get; init; } = 30; + + /// Selects the ingest shape — Plain topics or Sparkplug B. + public MqttMode Mode { get; init; } = MqttMode.Plain; + + /// + /// Sparkplug-only settings. Populated when is + /// ; null in Plain mode. + /// + public MqttSparkplugOptions? Sparkplug { get; init; } + + /// + /// Plain-mode-only settings. Populated when is + /// ; null in Sparkplug B mode. + /// + public MqttPlainOptions? Plain { get; init; } +} + +/// +/// Sparkplug B settings for an instance in +/// mode. See +/// docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md §5.1. +/// +public sealed record MqttSparkplugOptions +{ + /// Sparkplug group id — the driver subscribes spBv1.0/{GroupId}/#. + public string GroupId { get; init; } = ""; + + /// + /// Sparkplug Host Application identity — published as spBv1.0/STATE/{HostId} + /// (Sparkplug v3.0). + /// + public string HostId { get; init; } = ""; + + /// + /// When true, this driver instance acts as the Sparkplug Primary Host + /// Application. At most one primary host may exist per host-id per broker. + /// + public bool ActAsPrimaryHost { get; init; } = false; + + /// + /// When true, a detected sequence-number gap triggers a Sparkplug rebirth + /// request (NCMD) rather than silently continuing with stale metric state. + /// + public bool RequestRebirthOnGap { get; init; } = true; + + /// + /// How long, in seconds, browse/discovery collects NBIRTH/DBIRTH traffic before + /// considering the observed metric set stable. + /// + [Range(1, int.MaxValue)] + public int BirthObservationWindowSeconds { get; init; } = 15; +} + +/// +/// Plain-MQTT-mode settings for an instance in +/// mode. See +/// docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md §5.1. +/// +public sealed record MqttPlainOptions +{ + /// + /// Optional prefix prepended when the driver needs to compose a topic (e.g. discovery + /// scoping); per-tag topics are authored explicitly and are unaffected. + /// + public string TopicPrefix { get; init; } = ""; + + /// Default QoS used when a tag's own qos is unset. + [Range(0, 2)] + public int DefaultQos { get; init; } = 1; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttMode.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttMode.cs new file mode 100644 index 00000000..86f907c7 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttMode.cs @@ -0,0 +1,14 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt; + +/// +/// Selects the ingest shape an instance uses. See +/// docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md §5.1. +/// +public enum MqttMode +{ + /// Plain MQTT — the driver subscribes to authored topics directly. + Plain, + + /// Sparkplug B — the driver decodes Tahu-encoded NBIRTH/DBIRTH/NDATA/DDATA payloads. + SparkplugB, +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttPayloadFormat.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttPayloadFormat.cs new file mode 100644 index 00000000..c8ff9fa0 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttPayloadFormat.cs @@ -0,0 +1,17 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt; + +/// +/// How a Plain-mode MQTT tag's payload is decoded. See +/// docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md §5.3. +/// +public enum MqttPayloadFormat +{ + /// The payload is a JSON document; a JSONPath selects the value (jsonPath). + Json, + + /// The payload bytes are used as-is (e.g. binary / opaque). + Raw, + + /// The payload is a bare scalar string (e.g. "23.5"), parsed by dataType. + Scalar, +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttProtocolVersion.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttProtocolVersion.cs new file mode 100644 index 00000000..8a674e34 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttProtocolVersion.cs @@ -0,0 +1,14 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt; + +/// +/// MQTT wire protocol version negotiated at CONNECT. See +/// docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md §5.1. +/// +public enum MqttProtocolVersion +{ + /// MQTT 3.1.1. + V311, + + /// MQTT 5.0. + V500, +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj index 3645fe89..6c5a5492 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj @@ -6,9 +6,6 @@ true - - + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverOptionsTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverOptionsTests.cs new file mode 100644 index 00000000..29b6317f --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverOptionsTests.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Mqtt; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests; + +public sealed class MqttDriverOptionsTests +{ + private static readonly JsonSerializerOptions J = new() + { + PropertyNameCaseInsensitive = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + Converters = { new JsonStringEnumConverter() }, + }; + + [Fact] + public void Deserialize_SparkplugConfig_ReadsModeAndSubObject() + { + const string json = """ + { "host":"10.100.0.35","port":8883,"useTls":true,"mode":"SparkplugB", + "sparkplug":{"groupId":"Plant1","hostId":"h1","requestRebirthOnGap":true} } + """; + var o = JsonSerializer.Deserialize(json, J)!; + o.Mode.ShouldBe(MqttMode.SparkplugB); + o.UseTls.ShouldBeTrue(); + o.Sparkplug!.GroupId.ShouldBe("Plant1"); + o.Plain.ShouldBeNull(); + } + + [Fact] + public void Serialize_WritesEnumsAsNames_NotOrdinals() + { + var s = JsonSerializer.Serialize(new MqttDriverOptions { Mode = MqttMode.Plain }, J); + s.ShouldContain("\"Plain\""); + s.ShouldNotContain("\"mode\":0"); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests.csproj new file mode 100644 index 00000000..65591a1b --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + From a5a8710af5aec3a96ccf7d40c9dd0b60c3a7a429 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 14:01:01 -0400 Subject: [PATCH 03/43] fix(mqtt): pin security-critical defaults with tests + redact password in ToString MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review of f22db5d8 (approved-with-findings): the existing round-trip tests only exercised explicit JSON payloads, so nothing failed if UseTls / AllowUntrustedServerCertificate or the §5.1 numeric defaults were flipped. Adds a defaults-pinning test plus a partial-JSON test that omits the TLS knobs entirely (the production case — an operator config that just doesn't mention TLS must not silently land insecure). Also overrides the record's PrintMembers so ToString() no longer prints Password in plaintext (verified RED before the fix: the new test failed with the raw password in the rendered string). OpcUaClientDriverOptions has the same unredacted-ToString() shape but is out of scope for this task per the coordinator's note; flagging as a possible follow-up. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../MqttDriverOptions.cs | 37 +++++++++++++++++++ .../MqttDriverOptionsTests.cs | 34 +++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttDriverOptions.cs index 4fe77477..47e93a45 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttDriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttDriverOptions.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using System.Text; namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt; @@ -95,6 +96,42 @@ public sealed record MqttDriverOptions /// ; null in Sparkplug B mode. /// public MqttPlainOptions? Plain { get; init; } + + /// + /// Record-generated ToString() member printer, overridden so + /// never renders in plaintext — this DTO routinely lands in logs / exception messages / + /// debugger watches, and the plan's cross-cutting rule is explicit: never commit or log + /// creds. A set password prints as ***; unset (null/empty) renders + /// distinguishably so the redaction can't be mistaken for a real secret. + /// + private bool PrintMembers(StringBuilder builder) + { + var passwordDisplay = Password switch + { + null => "", + "" => "", + _ => "***", + }; + + builder.Append("Host = ").Append(Host); + builder.Append(", Port = ").Append(Port); + builder.Append(", ClientId = ").Append(ClientId); + builder.Append(", UseTls = ").Append(UseTls); + builder.Append(", AllowUntrustedServerCertificate = ").Append(AllowUntrustedServerCertificate); + builder.Append(", CaCertificatePath = ").Append(CaCertificatePath); + builder.Append(", Username = ").Append(Username); + builder.Append(", Password = ").Append(passwordDisplay); + builder.Append(", ProtocolVersion = ").Append(ProtocolVersion); + builder.Append(", CleanSession = ").Append(CleanSession); + builder.Append(", KeepAliveSeconds = ").Append(KeepAliveSeconds); + builder.Append(", ConnectTimeoutSeconds = ").Append(ConnectTimeoutSeconds); + builder.Append(", ReconnectMinBackoffSeconds = ").Append(ReconnectMinBackoffSeconds); + builder.Append(", ReconnectMaxBackoffSeconds = ").Append(ReconnectMaxBackoffSeconds); + builder.Append(", Mode = ").Append(Mode); + builder.Append(", Sparkplug = ").Append(Sparkplug); + builder.Append(", Plain = ").Append(Plain); + return true; + } } /// diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverOptionsTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverOptionsTests.cs index 29b6317f..ce7e2c3c 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverOptionsTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverOptionsTests.cs @@ -36,4 +36,38 @@ public sealed class MqttDriverOptionsTests s.ShouldContain("\"Plain\""); s.ShouldNotContain("\"mode\":0"); } + + // Pins the plan's cross-cutting rule ("never ship anonymous/public-broker defaults") as an + // executable assertion — nothing else in this suite fails if UseTls / AllowUntrustedServerCertificate + // (or the §5.1 numeric defaults) get flipped by an unrelated edit. + [Fact] + public void Defaults_SecurityCriticalKnobs_MatchPlan() + { + var o = new MqttDriverOptions(); + o.UseTls.ShouldBeTrue(); + o.AllowUntrustedServerCertificate.ShouldBeFalse(); + o.ConnectTimeoutSeconds.ShouldBe(15); + o.ReconnectMinBackoffSeconds.ShouldBe(1); + o.ReconnectMaxBackoffSeconds.ShouldBe(30); + } + + // The production case: an operator-authored config blob that simply doesn't mention TLS must + // never silently deserialize into an insecure instance. + [Fact] + public void Deserialize_PartialJsonOmittingTlsKnobs_KeepsSecureDefaults() + { + const string json = """{ "host":"10.100.0.35","port":8883 }"""; + var o = JsonSerializer.Deserialize(json, J)!; + o.UseTls.ShouldBeTrue(); + o.AllowUntrustedServerCertificate.ShouldBeFalse(); + } + + [Fact] + public void ToString_DoesNotLeakPassword() + { + var o = new MqttDriverOptions { Password = "hunter2-supersecret" }; + var s = o.ToString(); + s.ShouldNotContain("hunter2-supersecret"); + s.ShouldContain("***"); + } } From e3fea6e4093ac35bd44eff1566f5d53b988c9352 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 14:07:11 -0400 Subject: [PATCH 04/43] feat(mqtt): plain tag parser + strict-enum descriptor Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../MqttEquipmentTagParser.cs | 164 ++++++++++++++++++ .../MqttTagDefinition.cs | 65 +++++++ .../MqttEquipmentTagParserTests.cs | 143 +++++++++++++++ 3 files changed, 372 insertions(+) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttEquipmentTagParser.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinition.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttEquipmentTagParserTests.cs 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(); +} From 726d6d198e74a8413c899db841a24ac4ed32d603 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 14:24:04 -0400 Subject: [PATCH 05/43] feat(mqtt): MqttConnection connect/TLS/auth under bounded deadline Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- ZB.MOM.WW.OtOpcUa.slnx | 1 + .../MqttConnection.cs | 326 +++++++++++++++ .../ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj | 29 ++ .../MqttConnectionTests.cs | 373 ++++++++++++++++++ ...ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests.csproj | 1 + 5 files changed, 730 insertions(+) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttConnectionTests.cs diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 6e6ccab6..e07dc176 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -42,6 +42,7 @@ + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs new file mode 100644 index 00000000..54337abe --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs @@ -0,0 +1,326 @@ +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Logging; +using MQTTnet; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt; + +/// +/// Owns the driver's single live MQTTnet-5 client: assembles the broker connection options +/// from (endpoint, protocol version, session, credentials, +/// TLS + CA pin) and connects under a bounded deadline. +/// +/// +/// +/// Connection-free construction. The constructor stores configuration only — it +/// opens no socket and creates no client. The universal-browser CanBrowse pattern +/// constructs a throwaway instance purely to ask what driver type it is, so a +/// constructor that dialled a broker would stall the AdminUI. +/// +/// +/// Bounded connect. links the caller's token with a +/// deadline, and the same value is +/// fed to MQTTnet's own . A broker that accepts the +/// TCP connection and then never answers CONNACK — the frozen-peer shape that wedged the +/// S7 read path (arch-review R2-01) — fails at the deadline instead of hanging forever. +/// +/// +/// Credentials never leak. Nothing here logs or formats +/// ; the options record itself redacts it in +/// ToString(). +/// +/// Reconnect/backoff (Task 4) and subscription (Task 6) are deliberately not implemented here. +/// +public sealed class MqttConnection : IAsyncDisposable +{ + private readonly string _driverId; + private readonly ILogger? _logger; + private readonly MqttDriverOptions _options; + + private IMqttClient? _client; + private bool _disposed; + + /// Stores configuration only — no network, no client instantiation. + /// Broker connection settings. + /// Driver instance id, used only for log/diagnostic correlation. + /// Optional logger; never receives credentials. + public MqttConnection(MqttDriverOptions options, string driverId, ILogger? logger = null) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentException.ThrowIfNullOrWhiteSpace(driverId); + + _options = options; + _driverId = driverId; + _logger = logger; + } + + /// Whether the underlying client currently holds an established MQTT session. + public bool IsConnected => _client?.IsConnected ?? false; + + /// + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + _disposed = true; + + var client = Interlocked.Exchange(ref _client, null); + if (client is null) + { + return; + } + + try + { + if (client.IsConnected) + { + // Bounded even on teardown — a wedged broker must not stall driver shutdown. + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(_options.ConnectTimeoutSeconds)); + await client.DisconnectAsync(new MqttClientDisconnectOptions(), deadline.Token).ConfigureAwait(false); + } + } + catch (Exception ex) + { + _logger?.LogDebug(ex, "MQTT driver '{DriverId}': disconnect during dispose failed; disposing anyway.", _driverId); + } + finally + { + client.Dispose(); + } + } + + /// + /// Connects to the broker, failing at + /// rather than waiting indefinitely on an unresponsive peer. + /// + /// Caller cancellation; linked with the connect deadline. + /// The connect deadline elapsed. + /// was cancelled. + public async Task ConnectAsync(CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + // Single-caller by contract: the reconnect supervisor (Task 4) owns connect/disconnect + // sequencing, so no lock is taken here. The options are rebuilt per attempt so a rotated + // CA file is picked up on the next connect rather than being pinned for the process life. + var client = _client ??= new MqttClientFactory().CreateMqttClient(); + var clientOptions = BuildClientOptions(_options, clientIdSuffix: null, _logger); + + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(_options.ConnectTimeoutSeconds)); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, deadline.Token); + + _logger?.LogDebug( + "MQTT driver '{DriverId}': connecting to {Host}:{Port} (tls={UseTls}, timeout={TimeoutSeconds}s).", + _driverId, + _options.Host, + _options.Port, + _options.UseTls, + _options.ConnectTimeoutSeconds); + + try + { + await client.ConnectAsync(clientOptions, linked.Token).ConfigureAwait(false); + } + // MQTTnet wraps a cancelled connect in MqttConnectingFailedException rather than letting + // the OperationCanceledException surface, so the two legs are told apart by which token + // fired, not by the exception type. Caller intent wins over the deadline. + catch (Exception ex) when (cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException( + $"MQTT driver '{_driverId}': connect to {_options.Host}:{_options.Port} was cancelled.", + ex, + cancellationToken); + } + catch (Exception ex) when (deadline.IsCancellationRequested) + { + throw new TimeoutException( + $"MQTT driver '{_driverId}': connect to {_options.Host}:{_options.Port} did not complete within " + + $"{_options.ConnectTimeoutSeconds}s.", + ex); + } + } + + /// + /// Assembles the MQTTnet client options from . Pure — it performs + /// no I/O and touches no network, so it is safe to call on a config-validation path. A + /// configured is read lazily by the + /// returned certificate-validation callback at handshake time, not here. + /// + /// Broker connection settings. + /// + /// Appended to , so a browse/probe session can share + /// the configured identity without colliding with the driver's own client id. When both are + /// empty MQTTnet generates a random client id. + /// + /// Optional logger for the deferred CA-pin load; never receives credentials. + public static MqttClientOptions BuildClientOptions( + MqttDriverOptions options, + string? clientIdSuffix, + ILogger? logger = null) + { + ArgumentNullException.ThrowIfNull(options); + + var builder = new MqttClientOptionsBuilder() + .WithTcpServer(options.Host, options.Port) + .WithProtocolVersion(MapProtocolVersion(options.ProtocolVersion)) + .WithCleanSession(options.CleanSession) + .WithKeepAlivePeriod(TimeSpan.FromSeconds(options.KeepAliveSeconds)) + .WithTimeout(TimeSpan.FromSeconds(options.ConnectTimeoutSeconds)); + + var clientId = string.Concat(options.ClientId ?? string.Empty, clientIdSuffix ?? string.Empty); + if (!string.IsNullOrWhiteSpace(clientId)) + { + builder = builder.WithClientId(clientId); + } + + // An empty username means "connect anonymously"; MQTTnet would otherwise send an empty + // CONNECT username, which some brokers treat as a failed auth rather than as anonymous. + if (!string.IsNullOrEmpty(options.Username)) + { + builder = builder.WithCredentials(options.Username, options.Password ?? string.Empty); + } + + builder = builder.WithTlsOptions(tls => ConfigureTls(tls, options, logger)); + + return builder.Build(); + } + + private static void ConfigureTls(MqttClientTlsOptionsBuilder tls, MqttDriverOptions options, ILogger? logger) + { + if (!options.UseTls) + { + tls.UseTls(false); + return; + } + + tls.UseTls(true) + // Explicit SNI / hostname-verification target. Left unset, name validation depends on + // how MQTTnet infers the host from the endpoint — pin it to the configured host. + .WithTargetHost(options.Host); + + if (options.AllowUntrustedServerCertificate) + { + // Dev / on-prem escape hatch, off unless explicitly enabled — mirrors the + // ServerHistorian TLS knobs. This is the ONLY branch that accepts a bad certificate. + tls.WithAllowUntrustedCertificates(true) + .WithIgnoreCertificateChainErrors(true) + .WithCertificateValidationHandler(static _ => true); + return; + } + + if (string.IsNullOrWhiteSpace(options.CaCertificatePath)) + { + // No pin: MQTTnet's default handler validates against the OS trust store. + return; + } + + tls.WithCertificateValidationHandler(CreatePinnedCaValidator(options.CaCertificatePath, logger)); + } + + /// + /// Builds a certificate-validation callback that pins the broker chain to the PEM CA at + /// . The file is loaded lazily on first validation + /// (i.e. during the TLS handshake) so that this — and therefore + /// — stays free of I/O. Any failure to load or to chain + /// up to the pinned CA rejects the certificate: fail closed. + /// + private static Func CreatePinnedCaValidator( + string caCertificatePath, + ILogger? logger) + { + var trustedRoots = new Lazy( + () => LoadPemCa(caCertificatePath, logger), + LazyThreadSafetyMode.ExecutionAndPublication); + + return args => ValidateAgainstPinnedCa(args, trustedRoots.Value, caCertificatePath, logger); + } + + private static X509Certificate2Collection? LoadPemCa(string caCertificatePath, ILogger? logger) + { + try + { + var collection = new X509Certificate2Collection(); + collection.ImportFromPemFile(caCertificatePath); + if (collection.Count != 0) + { + return collection; + } + + logger?.LogError("MQTT CA pin '{CaCertificatePath}' contains no certificates; broker TLS will be rejected.", caCertificatePath); + } + catch (Exception ex) + { + logger?.LogError(ex, "MQTT CA pin '{CaCertificatePath}' could not be loaded; broker TLS will be rejected.", caCertificatePath); + } + + return null; + } + + private static bool ValidateAgainstPinnedCa( + MqttClientCertificateValidationEventArgs args, + X509Certificate2Collection? trustedRoots, + string caCertificatePath, + ILogger? logger) + { + if (trustedRoots is null || trustedRoots.Count == 0) + { + // Unreadable pin ⇒ no trust anchor ⇒ refuse. Never silently degrade to the OS store. + return false; + } + + // The pin replaces the trust anchor only. Anything else the platform flagged — a hostname + // mismatch, an absent certificate — remains fatal. + if ((args.SslPolicyErrors & ~SslPolicyErrors.RemoteCertificateChainErrors) != SslPolicyErrors.None) + { + logger?.LogError("MQTT broker certificate rejected: {SslPolicyErrors}.", args.SslPolicyErrors); + return false; + } + + if (args.Certificate is null) + { + return false; + } + + var presented = args.Certificate as X509Certificate2; + X509Certificate2? owned = null; + if (presented is null) + { + owned = X509CertificateLoader.LoadCertificate(args.Certificate.Export(X509ContentType.Cert)); + presented = owned; + } + + try + { + using var chain = new X509Chain(); + chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; + chain.ChainPolicy.CustomTrustStore.AddRange(trustedRoots); + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + + if (chain.Build(presented)) + { + return true; + } + + logger?.LogError( + "MQTT broker certificate does not chain to the pinned CA '{CaCertificatePath}': {ChainStatus}.", + caCertificatePath, + string.Join(", ", chain.ChainStatus.Select(s => s.Status))); + return false; + } + finally + { + owned?.Dispose(); + } + } + + private static MQTTnet.Formatter.MqttProtocolVersion MapProtocolVersion(MqttProtocolVersion version) + => version switch + { + MqttProtocolVersion.V311 => MQTTnet.Formatter.MqttProtocolVersion.V311, + MqttProtocolVersion.V500 => MQTTnet.Formatter.MqttProtocolVersion.V500, + _ => throw new ArgumentOutOfRangeException(nameof(version), version, "Unsupported MQTT protocol version."), + }; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj new file mode 100644 index 00000000..55ffef0b --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + latest + true + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Driver.Mqtt + ZB.MOM.WW.OtOpcUa.Driver.Mqtt + + + + + + + + + + + + + + + + + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttConnectionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttConnectionTests.cs new file mode 100644 index 00000000..45180483 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttConnectionTests.cs @@ -0,0 +1,373 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using MQTTnet; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests; + +/// +/// Covers 's two Task-3 responsibilities: the pure +/// options assembly (TLS / CA pin / +/// credentials / protocol knobs) and the bounded +/// deadline. +/// +public sealed class MqttConnectionTests +{ + private const string Host = "broker.example.test"; + + // --------------------------------------------------------------------------------- + // Bounded connect (the R2-01 frozen-peer lesson: no unbounded wait on a dead broker) + // --------------------------------------------------------------------------------- + + /// + /// A broker that completes the TCP handshake but never answers CONNACK is the shape that + /// actually wedges a client — a refused port returns ECONNREFUSED instantly and would pass + /// whether or not any deadline logic existed. + /// + [Fact] + public async Task ConnectAsync_BlackholeBroker_FailsWithinDeadline_NoHang() + { + using var blackhole = new BlackholeBroker(); + var opts = new MqttDriverOptions + { + Host = "127.0.0.1", Port = blackhole.Port, UseTls = false, ConnectTimeoutSeconds = 2, + }; + await using var conn = new MqttConnection(opts, driverId: "t", logger: null); + + var sw = Stopwatch.StartNew(); + await Should.ThrowAsync(async () => await conn.ConnectAsync(CancellationToken.None)); + sw.Stop(); + + sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10)); + conn.IsConnected.ShouldBeFalse(); + } + + /// + /// Falsifiability control for the linked-CTS wiring specifically: the configured connect + /// deadline is 60 s, so the only thing that can end this call inside 10 s is the caller's + /// token actually reaching MQTTnet. Drop the CreateLinkedTokenSource and this test + /// hangs for a minute. + /// + [Fact] + public async Task ConnectAsync_CallerCancellation_ObservedLongBeforeConnectDeadline() + { + using var blackhole = new BlackholeBroker(); + var opts = new MqttDriverOptions + { + Host = "127.0.0.1", Port = blackhole.Port, UseTls = false, ConnectTimeoutSeconds = 60, + }; + await using var conn = new MqttConnection(opts, driverId: "t", logger: null); + using var caller = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); + + var sw = Stopwatch.StartNew(); + await Should.ThrowAsync(async () => await conn.ConnectAsync(caller.Token)); + sw.Stop(); + + sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10)); + } + + /// + /// The deadline leg is distinguishable from a caller cancellation — a timed-out connect + /// surfaces , not a bare , + /// so an operator can tell "broker never answered" from "we were asked to stop". + /// + [Fact] + public async Task ConnectAsync_DeadlineElapsed_ThrowsTimeoutException() + { + using var blackhole = new BlackholeBroker(); + var opts = new MqttDriverOptions + { + Host = "127.0.0.1", Port = blackhole.Port, UseTls = false, ConnectTimeoutSeconds = 1, + }; + await using var conn = new MqttConnection(opts, driverId: "t", logger: null); + + await Should.ThrowAsync(async () => await conn.ConnectAsync(CancellationToken.None)); + } + + /// Never log or throw credentials — the plan's cross-cutting secrets rule. + [Fact] + public async Task ConnectAsync_Failure_DoesNotLeakPasswordIntoExceptionMessage() + { + const string secret = "sup3r-s3cret-broker-pw"; + using var blackhole = new BlackholeBroker(); + var opts = new MqttDriverOptions + { + Host = "127.0.0.1", + Port = blackhole.Port, + UseTls = false, + ConnectTimeoutSeconds = 1, + Username = "svc", + Password = secret, + }; + await using var conn = new MqttConnection(opts, driverId: "t", logger: null); + + var ex = await Should.ThrowAsync(async () => await conn.ConnectAsync(CancellationToken.None)); + + ex.ToString().ShouldNotContain(secret); + } + + // --------------------------------------------------------------------------------- + // BuildClientOptions — pure assembly, no I/O + // --------------------------------------------------------------------------------- + + [Fact] + public void BuildClientOptions_UseTlsWithCaPin_SetsTlsAndInstallsChainValidator() + { + var opts = new MqttDriverOptions + { + Host = Host, + Port = 8883, + UseTls = true, + AllowUntrustedServerCertificate = false, + CaCertificatePath = "/tmp/ca.pem", + }; + + var tls = BuildTls(opts); + + tls.UseTls.ShouldBeTrue(); + tls.TargetHost.ShouldBe(Host); + tls.AllowUntrustedCertificates.ShouldBeFalse(); + tls.IgnoreCertificateChainErrors.ShouldBeFalse(); + tls.CertificateValidationHandler.ShouldNotBeNull(); + } + + /// + /// The CA file is read lazily inside the validation callback, never by the builder — so a + /// path that does not exist yet must not throw at build time, and must fail the *connection* + /// closed when the handshake finally asks. + /// + [Fact] + public void BuildClientOptions_CaPinPathMissing_BuildsWithoutIo_AndValidatorFailsClosed() + { + var missing = Path.Combine(Path.GetTempPath(), $"otopcua-mqtt-no-such-ca-{Guid.NewGuid():N}.pem"); + File.Exists(missing).ShouldBeFalse(); + + var opts = new MqttDriverOptions { Host = Host, Port = 8883, UseTls = true, CaCertificatePath = missing }; + + var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null); + var tls = built.ChannelOptions.ShouldBeOfType().TlsOptions; + + tls.CertificateValidationHandler.ShouldNotBeNull(); + tls.CertificateValidationHandler(SelfSignedValidationArgs(built)).ShouldBeFalse(); + } + + /// An untrusted, unpinned server certificate is rejected — the pin actually validates. + [Fact] + public void BuildClientOptions_CaPin_RejectsCertificateNotIssuedByThePinnedCa() + { + var caPath = WriteTempPemCa(); + try + { + var opts = new MqttDriverOptions { Host = Host, Port = 8883, UseTls = true, CaCertificatePath = caPath }; + var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null); + var tls = built.ChannelOptions.ShouldBeOfType().TlsOptions; + + tls.CertificateValidationHandler!(SelfSignedValidationArgs(built)).ShouldBeFalse(); + } + finally + { + File.Delete(caPath); + } + } + + /// Default posture: OS trust store, no custom handler, escape hatch off. + [Fact] + public void BuildClientOptions_TlsWithoutCaPin_LeavesOsTrustStoreValidationInPlace() + { + var opts = new MqttDriverOptions { Host = Host, Port = 8883, UseTls = true }; + + var tls = BuildTls(opts); + + tls.UseTls.ShouldBeTrue(); + tls.CertificateValidationHandler.ShouldBeNull(); + tls.AllowUntrustedCertificates.ShouldBeFalse(); + tls.IgnoreCertificateChainErrors.ShouldBeFalse(); + tls.IgnoreCertificateRevocationErrors.ShouldBeFalse(); + } + + /// The dev/on-prem escape hatch only opens when explicitly asked for. + [Fact] + public void BuildClientOptions_AllowUntrustedServerCertificate_OpensEscapeHatch() + { + var opts = new MqttDriverOptions + { + Host = Host, Port = 8883, UseTls = true, AllowUntrustedServerCertificate = true, + }; + + var tls = BuildTls(opts); + + tls.AllowUntrustedCertificates.ShouldBeTrue(); + tls.IgnoreCertificateChainErrors.ShouldBeTrue(); + tls.CertificateValidationHandler.ShouldNotBeNull(); + } + + [Fact] + public void BuildClientOptions_UseTlsFalse_KeepsTlsOffAndInstallsNoValidator() + { + var opts = new MqttDriverOptions + { + Host = Host, Port = 1883, UseTls = false, AllowUntrustedServerCertificate = true, CaCertificatePath = "/tmp/ca.pem", + }; + + var tls = BuildTls(opts); + + tls.UseTls.ShouldBeFalse(); + tls.CertificateValidationHandler.ShouldBeNull(); + tls.AllowUntrustedCertificates.ShouldBeFalse(); + } + + [Fact] + public void BuildClientOptions_MapsProtocolAndSessionKnobs() + { + var opts = new MqttDriverOptions + { + Host = Host, + Port = 8883, + ClientId = "otopcua-node-1", + ProtocolVersion = MqttProtocolVersion.V311, + CleanSession = false, + KeepAliveSeconds = 45, + ConnectTimeoutSeconds = 7, + }; + + var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null); + + built.ProtocolVersion.ShouldBe(MQTTnet.Formatter.MqttProtocolVersion.V311); + built.ClientId.ShouldBe("otopcua-node-1"); + built.CleanSession.ShouldBeFalse(); + built.KeepAlivePeriod.ShouldBe(TimeSpan.FromSeconds(45)); + built.Timeout.ShouldBe(TimeSpan.FromSeconds(7)); + var tcp = built.ChannelOptions.ShouldBeOfType(); + tcp.RemoteEndpoint.ShouldBeOfType().Host.ShouldBe(Host); + tcp.RemoteEndpoint.ShouldBeOfType().Port.ShouldBe(8883); + } + + [Fact] + public void BuildClientOptions_ClientIdSuffix_IsAppended() + { + var opts = new MqttDriverOptions { Host = Host, ClientId = "otopcua" }; + + MqttConnection.BuildClientOptions(opts, clientIdSuffix: "-browse").ClientId.ShouldBe("otopcua-browse"); + } + + [Fact] + public void BuildClientOptions_NoUsername_SendsNoCredentials() + => MqttConnection.BuildClientOptions(new MqttDriverOptions { Host = Host }, clientIdSuffix: null) + .Credentials.ShouldBeNull(); + + [Fact] + public void BuildClientOptions_Username_SendsCredentials() + { + var opts = new MqttDriverOptions { Host = Host, Username = "svc", Password = "pw" }; + + var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null); + + built.Credentials.ShouldNotBeNull(); + built.Credentials.GetUserName(built).ShouldBe("svc"); + } + + /// The ctor must not touch the network — the universal-browser throwaway-instance pattern. + [Fact] + public void Ctor_IsConnectionFree() + { + var opts = new MqttDriverOptions { Host = "127.0.0.1", Port = 1, UseTls = false }; + + var conn = new MqttConnection(opts, driverId: "t", logger: null); + + conn.IsConnected.ShouldBeFalse(); + } + + // --------------------------------------------------------------------------------- + // helpers + // --------------------------------------------------------------------------------- + + private static MqttClientTlsOptions BuildTls(MqttDriverOptions opts) + => MqttConnection.BuildClientOptions(opts, clientIdSuffix: null) + .ChannelOptions.ShouldBeOfType().TlsOptions; + + private static MqttClientCertificateValidationEventArgs SelfSignedValidationArgs(MqttClientOptions built) + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest($"CN={Host}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var cert = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1)); + return new MqttClientCertificateValidationEventArgs( + cert, + new X509Chain(), + SslPolicyErrors.RemoteCertificateChainErrors, + built.ChannelOptions); + } + + private static string WriteTempPemCa() + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest("CN=otopcua-test-ca", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + using var ca = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1)); + var path = Path.Combine(Path.GetTempPath(), $"otopcua-mqtt-ca-{Guid.NewGuid():N}.pem"); + File.WriteAllText(path, ca.ExportCertificatePem()); + return path; + } + + /// + /// Accepts the TCP connection and then says nothing — the client sends CONNECT and waits + /// forever for a CONNACK that never comes. This is the frozen-peer shape; a closed port is + /// not (it refuses instantly and proves nothing about a deadline). + /// + private sealed class BlackholeBroker : IDisposable + { + private readonly List _accepted = []; + private readonly CancellationTokenSource _cts = new(); + private readonly TcpListener _listener; + + public BlackholeBroker() + { + _listener = new TcpListener(IPAddress.Loopback, 0); + _listener.Start(); + Port = ((IPEndPoint)_listener.LocalEndpoint).Port; + _ = Task.Run(AcceptLoopAsync); + } + + public int Port { get; } + + public void Dispose() + { + _cts.Cancel(); + _listener.Stop(); + lock (_accepted) + { + foreach (var client in _accepted) + { + client.Dispose(); + } + + _accepted.Clear(); + } + + _cts.Dispose(); + } + + private async Task AcceptLoopAsync() + { + try + { + while (!_cts.IsCancellationRequested) + { + var client = await _listener.AcceptTcpClientAsync(_cts.Token); + lock (_accepted) + { + _accepted.Add(client); + } + } + } + catch (Exception) + { + // Listener stopped / token cancelled — the fixture is going away. + } + } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests.csproj index 65591a1b..2ad93142 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests.csproj +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests.csproj @@ -21,6 +21,7 @@ + From ded4c417985107ce4cc4e8f7c61b6e7e64a3179d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 14:31:36 -0400 Subject: [PATCH 06/43] fix(mqtt): key tag definitions by RawPath, not the TagConfig blob Task 2 review follow-up. The plan specified MqttEquipmentTagParser.TryParse(reference) with def.Name = the TagConfig blob, and told us to mirror a type named ModbusEquipmentTagParser. Both are plan defects: - EquipmentTagRefResolver documents that a v3 driver reference "is now always a RawPath" and that the blob-parse fallback is retired. Keying Name by the blob would make OnDataChange publish under a reference that never matches the RawPath-keyed fan-out in DriverHostActor - silently dead in production with every unit test still green. - ModbusEquipmentTagParser does not exist. The six sibling drivers all use TagDefinitionFactory.FromTagConfig(tagConfig, rawPath, out def). Changes: - Rename MqttEquipmentTagParser -> MqttTagDefinitionFactory; TryParse(reference, out def) -> FromTagConfig(tagConfig, rawPath, out def) setting Name: rawPath, matching ModbusTagDefinitionFactory's structure, param docs and guard order. - Pin the identity contract with a dedicated test so a regression to blob-keying goes red. - Read qos with the same strictness as the enums: a present-but-invalid qos ("high" / 1.5 / 5 / null) now rejects the tag and is warned by Inspect, instead of being silently absorbed into the driver-level default and handing the operator a weaker delivery guarantee than the one they authored. - No ToTagConfig inverse: the siblings carry one solely for their Driver..Cli project, the MQTT plan defines none, and the AdminUI editor template references no driver factory. Recorded as an explicit YAGNI call in the type doc rather than added speculatively. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../MqttEquipmentTagParser.cs | 164 ------------- .../MqttTagDefinition.cs | 17 +- .../MqttTagDefinitionFactory.cs | 198 ++++++++++++++++ .../MqttEquipmentTagParserTests.cs | 143 ----------- .../MqttTagDefinitionFactoryTests.cs | 224 ++++++++++++++++++ 5 files changed, 432 insertions(+), 314 deletions(-) delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttEquipmentTagParser.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinitionFactory.cs delete mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttEquipmentTagParserTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttTagDefinitionFactoryTests.cs 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 deleted file mode 100644 index 24e33600..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttEquipmentTagParser.cs +++ /dev/null @@ -1,164 +0,0 @@ -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 index 0d139be5..d4f6663b 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinition.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinition.cs @@ -3,10 +3,10 @@ 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 driver's internal per-tag descriptor — the parsed form of an authored raw 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 @@ -18,9 +18,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt; /// /// /// -/// 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 definition's identity: the tag's RawPath — the cluster-scoped slash path that is the +/// v3 driver wire reference. This is the key resolves +/// on, the key OnDataChange must publish under, and the key DriverHostActor fans out +/// to the raw + UNS NodeIds. It is emphatically not the authored TagConfig blob: the +/// pre-v3 blob-as-reference shape is retired, and publishing under a blob key would silently miss +/// the RawPath-keyed fan-out while every unit test still passed. /// /// The concrete MQTT topic this tag subscribes to (Plain mode). /// How the received payload is decoded (Plain mode). diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinitionFactory.cs new file mode 100644 index 00000000..697256b1 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinitionFactory.cs @@ -0,0 +1,198 @@ +using System.Text.Json; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt; + +/// +/// v3 pure mapper: turns an authored raw tag's TagConfig JSON (the shape produced by the +/// AdminUI MqttTagConfigModel) into an . Under v3 a tag's +/// identity is its RawPath (a cluster-scoped slash path), not the address blob — so the +/// produced definition's is the RawPath the driver was handed, +/// which is exactly the wire reference the driver's RawPath → def resolver keys on. The driver +/// builds that table by mapping each the deploy artifact delivers through +/// . +/// +/// Two entry points carry deliberately different strictness contracts. +/// is the runtime path: it never throws, and 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. +/// +/// +/// No ToTagConfig inverse — a deliberate YAGNI call. The six sibling factories +/// each carry one solely to serve their Driver.<X>.Cli project, which synthesises +/// blobs from operator flags; the MQTT plan defines no +/// Mqtt.Cli. The AdminUI typed editor does not need one either — the +/// <Driver>TagConfigModel template serialises through its own preserved +/// JsonObject key bag and references no driver factory (verified: no +/// TagDefinitionFactory reference exists anywhere under the AdminUI project). Add the +/// inverse when a real caller appears, not before. +/// +/// +public static class MqttTagDefinitionFactory +{ + /// 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 = ['+', '#']; + + /// + /// Maps an authored TagConfig object to a typed definition keyed by . + /// The input is always an authored TagConfig JSON object (there is no name-vs-blob heuristic in v3). + /// Enum fields (payloadFormat / dataType) and qos are read STRICTLY — a + /// present-but-invalid (typo'd) value rejects the whole tag (returns ⇒ the + /// driver surfaces BadNodeIdUnknown) rather than silently defaulting to a wrong-typed Good or + /// a downgraded delivery guarantee. Never throws. + /// + /// 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 authored equipment-tag TagConfig JSON. + /// The tag's RawPath — becomes the definition's identity (Name). + /// The mapped definition when this returns . + /// when is a valid MQTT tag-config object. + public static bool FromTagConfig(string tagConfig, string rawPath, out MqttTagDefinition def) + { + def = null!; + if (string.IsNullOrWhiteSpace(tagConfig) || tagConfig[0] != '{') return false; + try + { + using var doc = JsonDocument.Parse(tagConfig); + 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 read with the same strictness, for the same reason: silently absorbing a malformed + // "qos":"high" / "qos":1.5 / "qos":5 into the driver-level default would hand the operator a + // WEAKER delivery guarantee than the one they authored, with nothing to show for it. + if (!TryReadQosStrict(root, out var qos)) return false; + + var jsonPath = ReadString(root, "jsonPath"); + if (string.IsNullOrEmpty(jsonPath)) jsonPath = RootJsonPath; + + def = new MqttTagDefinition( + Name: rawPath, + 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 / + /// qos 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 an equipment-tag TagConfig object. 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"), + DescribeInvalidQos(root), + }) + { + if (w is not null) warnings.Add(w); + } + var topic = ReadString(root, "topic"); + if (topic.IndexOfAny(TopicWildcards) >= 0) + { + warnings.Add( + $"value '{topic}' for '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; + } + + /// + /// Strict qos read, mirroring 's + /// absent / valid / present-but-invalid split: absent ⇒ (the driver-level + /// wins); a JSON integer in 0–2 ⇒ that value; anything + /// else present (non-number, non-integer, or out of range) ⇒ the read FAILS. + /// + /// The TagConfig root object. + /// The parsed QoS, or when the field is absent. + /// only when qos is present but not a legal MQTT QoS. + private static bool TryReadQosStrict(JsonElement o, out int? qos) + { + qos = null; + if (!o.TryGetProperty("qos", out var e)) return true; // absent + if (e.ValueKind != JsonValueKind.Number || !e.TryGetInt32(out var v)) return false; + if (v is < 0 or > 2) return false; + qos = v; + return true; + } + + /// + /// A human-readable warning for a present-but-invalid qos field, or + /// when it is absent or valid — the qos counterpart of + /// . + /// + /// The TagConfig root object. + /// The warning text, or . + private static string? DescribeInvalidQos(JsonElement o) + { + if (!o.TryGetProperty("qos", out var e)) return null; + if (e.ValueKind == JsonValueKind.Number && e.TryGetInt32(out var v) && v is >= 0 and <= 2) return null; + return $"value '{e.GetRawText()}' for 'qos' is not a valid MQTT QoS; valid: 0, 1, 2"; + } + + private static string ReadString(JsonElement o, string name) + => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String + ? e.GetString() ?? "" + : ""; + + 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/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttEquipmentTagParserTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttEquipmentTagParserTests.cs deleted file mode 100644 index 037f7b2a..00000000 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttEquipmentTagParserTests.cs +++ /dev/null @@ -1,143 +0,0 @@ -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(); -} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttTagDefinitionFactoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttTagDefinitionFactoryTests.cs new file mode 100644 index 00000000..6bd57140 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttTagDefinitionFactoryTests.cs @@ -0,0 +1,224 @@ +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: FromTagConfig 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 MqttTagDefinitionFactoryTests +{ + private const string RawPath = "Plant/Mqtt/oven/Temp"; + + [Fact] + public void FromTagConfig_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 FromTagConfig_TypoedDataType_RejectsStrict. + const string blob = """{"topic":"factory/oven/temp","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64","qos":1}"""; + MqttTagDefinitionFactory.FromTagConfig(blob, RawPath, 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); + } + + /// + /// The v3 identity contract, stated explicitly so a regression back to blob-keying goes red. + /// MUST be the RawPath the driver was handed — it is the key + /// EquipmentTagRefResolver looks up, the key OnDataChange publishes under, and the + /// key DriverHostActor fans out to the raw + UNS NodeIds. Keying it by the TagConfig blob + /// (the pre-v3, now-retired shape) would leave the driver silently dead in production while every + /// other test here still passed. + /// + [Fact] + public void FromTagConfig_DefinitionIdentity_IsTheRawPath_NotTheBlob() + { + const string blob = """{"topic":"factory/oven/temp","payloadFormat":"Raw"}"""; + MqttTagDefinitionFactory.FromTagConfig(blob, RawPath, out var def).ShouldBeTrue(); + def.Name.ShouldBe(RawPath); + def.Name.ShouldNotBe(blob); + } + + /// + /// The identity is whatever RawPath the caller supplies — the factory must never re-derive it + /// from the blob's contents (e.g. from topic), or two tags sharing a topic would collide. + /// + [Theory] + [InlineData("Plant/Mqtt/oven/Temp")] + [InlineData("SiteA/Mqtt/line3/Oven/Setpoint")] + public void FromTagConfig_UsesSuppliedRawPathVerbatim(string rawPath) + { + MqttTagDefinitionFactory.FromTagConfig( + """{"topic":"shared/topic","payloadFormat":"Raw"}""", rawPath, out var def).ShouldBeTrue(); + def.Name.ShouldBe(rawPath); + } + + [Fact] + public void FromTagConfig_TypoedPayloadFormat_RejectsStrict() + => MqttTagDefinitionFactory.FromTagConfig( + """{"topic":"a/b","payloadFormat":"Jason","dataType":"Float64"}""", RawPath, out _).ShouldBeFalse(); + + [Fact] + public void FromTagConfig_TypoedDataType_RejectsStrict() + => MqttTagDefinitionFactory.FromTagConfig( + """{"topic":"a/b","payloadFormat":"Json","dataType":"Double"}""", RawPath, out _).ShouldBeFalse(); + + [Fact] + public void Inspect_WildcardTopic_ReturnsWarning() + { + // The blob is otherwise clean (payloadFormat parses, dataType + qos absent), so the wildcard + // check is the ONLY thing that can produce a warning — this cannot pass vacuously. + var warnings = MqttTagDefinitionFactory.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) + => MqttTagDefinitionFactory.Inspect($$"""{"topic":"{{topic}}","payloadFormat":"Raw"}""").ShouldNotBeEmpty(); + + [Fact] + public void FromTagConfig_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. + MqttTagDefinitionFactory.FromTagConfig( + """{"topic":"a/+/c","payloadFormat":"Raw"}""", RawPath, out var def).ShouldBeTrue(); + def.Topic.ShouldBe("a/+/c"); + } + + [Fact] + public void FromTagConfig_RawFormat_AppliesDefaults() + { + MqttTagDefinitionFactory.FromTagConfig( + """{"topic":"factory/oven/blob","payloadFormat":"Raw"}""", RawPath, 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 FromTagConfig_ExplicitRetainSeedFalse_IsHonoured() + { + MqttTagDefinitionFactory.FromTagConfig( + """{"topic":"a/b","payloadFormat":"Raw","retainSeed":false}""", RawPath, out var def).ShouldBeTrue(); + def.RetainSeed.ShouldBeFalse(); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void FromTagConfig_EachLegalQos_IsAccepted(int qos) + { + MqttTagDefinitionFactory.FromTagConfig( + $$"""{"topic":"a/b","payloadFormat":"Raw","qos":{{qos}}}""", RawPath, out var def).ShouldBeTrue(); + def.Qos.ShouldBe(qos); + } + + [Fact] + public void FromTagConfig_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. + MqttTagDefinitionFactory.FromTagConfig( + """{"topic":"a/b","payloadFormat":"Raw"}""", RawPath, 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":" ","payloadFormat":"Raw"}""")] // whitespace-only topic + public void FromTagConfig_MalformedBlob_ReturnsFalseAndNeverThrows(string? tagConfig) + => MqttTagDefinitionFactory.FromTagConfig(tagConfig!, RawPath, out _).ShouldBeFalse(); + + /// + /// A present-but-invalid qos is rejected in every malformed shape, not just the + /// out-of-range numeric one. Silently absorbing "high" / 1.5 into the driver-level + /// default would hand the operator a WEAKER delivery guarantee than the one they authored. + /// + [Theory] + [InlineData("5")] // out of range + [InlineData("-1")] // out of range + [InlineData("\"high\"")] // wrong JSON type + [InlineData("\"1\"")] // stringly-typed number + [InlineData("1.5")] // non-integer + [InlineData("true")] // wrong JSON type + [InlineData("null")] // an explicit null is not "absent" + public void FromTagConfig_MalformedQos_RejectsStrict(string qosToken) + => MqttTagDefinitionFactory.FromTagConfig( + $$"""{"topic":"a/b","payloadFormat":"Raw","qos":{{qosToken}}}""", RawPath, out _).ShouldBeFalse(); + + [Fact] + public void Inspect_CleanBlob_ReturnsEmpty() + => MqttTagDefinitionFactory.Inspect( + """{"topic":"a/b","payloadFormat":"Json","jsonPath":"$.v","dataType":"Float64","qos":2}""").ShouldBeEmpty(); + + [Fact] + public void Inspect_TypoedEnums_WarnsPerField() + { + var warnings = MqttTagDefinitionFactory.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)); + } + + [Theory] + [InlineData("5")] + [InlineData("\"high\"")] + [InlineData("1.5")] + public void Inspect_MalformedQos_Warns(string qosToken) + { + var warnings = MqttTagDefinitionFactory.Inspect( + $$"""{"topic":"a/b","payloadFormat":"Raw","qos":{{qosToken}}}"""); + warnings.ShouldHaveSingleItem().ShouldContain("qos", Case.Sensitive); + } + + /// Every warning source fires together — the pass reports all of them, not just the first. + [Fact] + public void Inspect_MultipleProblems_ReportsAll() + { + var warnings = MqttTagDefinitionFactory.Inspect( + """{"topic":"a/+/c","payloadFormat":"Jason","dataType":"Double","qos":9}"""); + warnings.Count.ShouldBe(4); + } + + [Fact] + public void Inspect_UnparseableBlob_Warns() + => MqttTagDefinitionFactory.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) + => MqttTagDefinitionFactory.Inspect(reference!).ShouldBeEmpty(); +} From 5629f3698d47247edae912b94b2ca8affe2b4f5e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 14:38:16 -0400 Subject: [PATCH 07/43] fix(mqtt): pin the CA accept path, honour presented intermediates, classify the dispose race Review follow-ups on MqttConnection (Task 3): - Every certificate test asserted rejection, so the accept branch was unreachable-by-regression. Adds a leaf genuinely issued by the pinned CA and asserts acceptance. - ValidateAgainstPinnedCa never seeded ChainPolicy.ExtraStore from the incoming chain, so a leaf behind an intermediate delivered during the handshake failed despite a legitimate path to the pinned root. Seeds from both the incoming chain's elements and its ExtraStore; CustomRootTrust still means only the pinned roots may terminate the chain. - A DisposeAsync racing an in-flight connect escaped as an unclassified exception; it now folds into ObjectDisposedException. - Promotes the single-caller concurrency invariant into the type remarks, with the accurate blast radius (a leaked live connection, not a benign throw). Serialising the lifecycle remains Task 4's job. - X509Chain.Build can throw; an exception escaping a TLS validation callback is an opaque handshake crash, so it is caught and refused. - Adds a connect-retry test (Task 4's reconnect loop reuses the instance) and a disposed-then-connect test. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../MqttConnection.cs | 72 ++++++- .../MqttConnectionTests.cs | 188 ++++++++++++++++-- 2 files changed, 239 insertions(+), 21 deletions(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs index 54337abe..1a6e9072 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs @@ -1,4 +1,5 @@ using System.Net.Security; +using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using Microsoft.Extensions.Logging; using MQTTnet; @@ -29,6 +30,18 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt; /// ; the options record itself redacts it in /// ToString(). /// +/// +/// NOT thread-safe — single-caller by contract. and +/// take no lock and must never run concurrently. Calling them +/// concurrently leaks a live broker connection: can +/// observe _disposed == false, set it, exchange a still-null _client for +/// null, and return having disposed nothing — after which the in-flight +/// creates a client, assigns it and connects successfully. +/// That client holds an open socket that no later can ever +/// reach, because _disposed is already true. Serialising the lifecycle is +/// the job of the reconnect supervisor added in Task 4; it is deliberately not solved +/// here. +/// /// Reconnect/backoff (Task 4) and subscription (Task 6) are deliberately not implemented here. /// public sealed class MqttConnection : IAsyncDisposable @@ -96,16 +109,24 @@ public sealed class MqttConnection : IAsyncDisposable /// Connects to the broker, failing at /// rather than waiting indefinitely on an unresponsive peer. /// + /// + /// May be retried on the same instance after a failed attempt — the underlying + /// is created once and reused across attempts, which is what the + /// Task-4 reconnect loop depends on. Not safe to call concurrently with itself or with + /// ; see the type-level remarks. + /// /// Caller cancellation; linked with the connect deadline. /// The connect deadline elapsed. /// was cancelled. + /// + /// The connection was disposed, either before the attempt started or while it was in flight. + /// public async Task ConnectAsync(CancellationToken cancellationToken) { ObjectDisposedException.ThrowIf(_disposed, this); - // Single-caller by contract: the reconnect supervisor (Task 4) owns connect/disconnect - // sequencing, so no lock is taken here. The options are rebuilt per attempt so a rotated - // CA file is picked up on the next connect rather than being pinned for the process life. + // The options are rebuilt per attempt so a rotated CA file is picked up on the next + // connect rather than being pinned for the life of the process. var client = _client ??= new MqttClientFactory().CreateMqttClient(); var clientOptions = BuildClientOptions(_options, clientIdSuffix: null, _logger); @@ -124,6 +145,18 @@ public sealed class MqttConnection : IAsyncDisposable { await client.ConnectAsync(clientOptions, linked.Token).ConfigureAwait(false); } + // A concurrent DisposeAsync disposes the client out from under the awaiting connect, and + // whatever MQTTnet throws for that matches neither token filter. Classify it rather than + // letting an undocumented exception escape the contract below. + catch (Exception ex) when (_disposed) + { + throw new ObjectDisposedException( + nameof(MqttConnection), + new InvalidOperationException( + $"MQTT driver '{_driverId}': connect to {_options.Host}:{_options.Port} was aborted because the " + + "connection was disposed while the attempt was in flight.", + ex)); + } // MQTTnet wraps a cancelled connect in MqttConnectingFailedException rather than letting // the OperationCanceledException surface, so the two legs are told apart by which token // fired, not by the exception type. Caller intent wins over the deadline. @@ -297,8 +330,30 @@ public sealed class MqttConnection : IAsyncDisposable using var chain = new X509Chain(); chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; chain.ChainPolicy.CustomTrustStore.AddRange(trustedRoots); + + // Revocation is deliberately not checked: the pin exists precisely for self-issued + // dev / on-prem CAs, which typically publish no CRL or OCSP responder, so checking + // would fail every such chain. The pin itself is the revocation story — remove the CA. chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + // Seed the intermediates the broker presented during the handshake. A leaf signed by + // an intermediate that chains up to the pinned root is a common broker topology, and + // that intermediate arrives on the wire rather than being installed locally — without + // it in the candidate pool a legitimate trust path simply cannot be built. + // CustomRootTrust still means only `trustedRoots` may terminate the chain, so a rogue + // self-signed cert smuggled in here cannot become an anchor. + // Both sources are read: platforms differ in whether the peer-supplied intermediates + // land in the incoming chain's elements, its ExtraStore, or both. + if (args.Chain is not null) + { + foreach (var element in args.Chain.ChainElements) + { + chain.ChainPolicy.ExtraStore.Add(element.Certificate); + } + + chain.ChainPolicy.ExtraStore.AddRange(args.Chain.ChainPolicy.ExtraStore); + } + if (chain.Build(presented)) { return true; @@ -310,6 +365,17 @@ public sealed class MqttConnection : IAsyncDisposable string.Join(", ", chain.ChainStatus.Select(s => s.Status))); return false; } + catch (CryptographicException ex) + { + // X509Chain.Build throws on a malformed certificate or an unusable policy. An exception + // escaping a TLS validation callback surfaces as an opaque handshake crash, so classify + // it here and refuse: an unverifiable certificate is a rejected certificate. + logger?.LogError( + ex, + "MQTT broker certificate could not be validated against the pinned CA '{CaCertificatePath}'; rejecting.", + caCertificatePath); + return false; + } finally { owned?.Dispose(); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttConnectionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttConnectionTests.cs index 45180483..c95cb714 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttConnectionTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttConnectionTests.cs @@ -89,6 +89,45 @@ public sealed class MqttConnectionTests await Should.ThrowAsync(async () => await conn.ConnectAsync(CancellationToken.None)); } + /// + /// Task 4's reconnect loop retries connect on the SAME instance, reusing the + /// IMqttClient held across failures. A failed attempt must therefore leave the + /// connection usable rather than poisoned. + /// + [Fact] + public async Task ConnectAsync_CanBeRetriedOnTheSameInstanceAfterAFailedAttempt() + { + using var blackhole = new BlackholeBroker(); + var opts = new MqttDriverOptions + { + Host = "127.0.0.1", Port = blackhole.Port, UseTls = false, ConnectTimeoutSeconds = 1, + }; + await using var conn = new MqttConnection(opts, driverId: "t", logger: null); + + await Should.ThrowAsync(async () => await conn.ConnectAsync(CancellationToken.None)); + + // Second attempt reaches the broker again and fails the same way — not ObjectDisposed, + // not a null client, not a silently-swallowed no-op. + await Should.ThrowAsync(async () => await conn.ConnectAsync(CancellationToken.None)); + conn.IsConnected.ShouldBeFalse(); + } + + /// + /// Connecting a disposed instance is an — the same + /// classification the in-flight dispose race folds into. Dispose is idempotent. + /// + [Fact] + public async Task ConnectAsync_AfterDispose_ThrowsObjectDisposed_AndDisposeIsIdempotent() + { + var opts = new MqttDriverOptions { Host = "127.0.0.1", Port = 1, UseTls = false, ConnectTimeoutSeconds = 1 }; + var conn = new MqttConnection(opts, driverId: "t", logger: null); + + await conn.DisposeAsync(); + await conn.DisposeAsync(); + + await Should.ThrowAsync(async () => await conn.ConnectAsync(CancellationToken.None)); + } + /// Never log or throw credentials — the plan's cross-cutting secrets rule. [Fact] public async Task ConnectAsync_Failure_DoesNotLeakPasswordIntoExceptionMessage() @@ -153,21 +192,76 @@ public sealed class MqttConnectionTests var tls = built.ChannelOptions.ShouldBeOfType().TlsOptions; tls.CertificateValidationHandler.ShouldNotBeNull(); - tls.CertificateValidationHandler(SelfSignedValidationArgs(built)).ShouldBeFalse(); + using var stranger = SelfSignedLeaf(Host); + tls.CertificateValidationHandler(ValidationArgs(built, stranger)).ShouldBeFalse(); } /// An untrusted, unpinned server certificate is rejected — the pin actually validates. [Fact] public void BuildClientOptions_CaPin_RejectsCertificateNotIssuedByThePinnedCa() { - var caPath = WriteTempPemCa(); + using var ca = IssueCa(issuer: null, "otopcua-test-ca", validDays: 30); + var caPath = WritePem(ca); try { var opts = new MqttDriverOptions { Host = Host, Port = 8883, UseTls = true, CaCertificatePath = caPath }; var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null); var tls = built.ChannelOptions.ShouldBeOfType().TlsOptions; - tls.CertificateValidationHandler!(SelfSignedValidationArgs(built)).ShouldBeFalse(); + using var stranger = SelfSignedLeaf(Host); + tls.CertificateValidationHandler!(ValidationArgs(built, stranger)).ShouldBeFalse(); + } + finally + { + File.Delete(caPath); + } + } + + /// + /// The accept path. Without this every certificate test asserts rejection, so deleting the + /// return true — or getting TrustMode / CustomTrustStore wrong — would + /// leave the suite green while no broker could ever connect. + /// + [Fact] + public void BuildClientOptions_CaPin_AcceptsLeafIssuedByThePinnedCa() + { + using var ca = IssueCa(issuer: null, "otopcua-test-ca", validDays: 30); + using var leaf = IssueLeaf(ca, Host, validDays: 20); + var caPath = WritePem(ca); + try + { + var opts = new MqttDriverOptions { Host = Host, Port = 8883, UseTls = true, CaCertificatePath = caPath }; + var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null); + var tls = built.ChannelOptions.ShouldBeOfType().TlsOptions; + + tls.CertificateValidationHandler!(ValidationArgs(built, leaf)).ShouldBeTrue(); + } + finally + { + File.Delete(caPath); + } + } + + /// + /// The common real-world broker topology: the leaf is signed by an intermediate that chains + /// to the pinned root, and the intermediate is delivered in the handshake rather than being + /// installed locally. Only the pinned ROOT is written to the CA file — the intermediate must + /// be picked up from what the broker presented. + /// + [Fact] + public void BuildClientOptions_CaPin_AcceptsLeafBehindAnIntermediateThatTheBrokerPresented() + { + using var root = IssueCa(issuer: null, "otopcua-test-root", validDays: 30); + using var intermediate = IssueCa(root, "otopcua-test-intermediate", validDays: 25); + using var leaf = IssueLeaf(intermediate, Host, validDays: 20); + var caPath = WritePem(root); + try + { + var opts = new MqttDriverOptions { Host = Host, Port = 8883, UseTls = true, CaCertificatePath = caPath }; + var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null); + var tls = built.ChannelOptions.ShouldBeOfType().TlsOptions; + + tls.CertificateValidationHandler!(ValidationArgs(built, leaf, intermediate)).ShouldBeTrue(); } finally { @@ -290,29 +384,87 @@ public sealed class MqttConnectionTests => MqttConnection.BuildClientOptions(opts, clientIdSuffix: null) .ChannelOptions.ShouldBeOfType().TlsOptions; - private static MqttClientCertificateValidationEventArgs SelfSignedValidationArgs(MqttClientOptions built) + private static readonly DateTimeOffset NotBefore = DateTimeOffset.UtcNow.AddDays(-1); + + /// A CA certificate, self-signed when is null. + private static X509Certificate2 IssueCa(X509Certificate2? issuer, string commonName, int validDays) { - using var rsa = RSA.Create(2048); - var request = new CertificateRequest($"CN={Host}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - var cert = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1)); - return new MqttClientCertificateValidationEventArgs( - cert, - new X509Chain(), - SslPolicyErrors.RemoteCertificateChainErrors, - built.ChannelOptions); + using var key = RSA.Create(2048); + var request = new CertificateRequest($"CN={commonName}", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + request.CertificateExtensions.Add( + new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true)); + request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); + + if (issuer is null) + { + return request.CreateSelfSigned(NotBefore, NotBefore.AddDays(validDays)); + } + + // Create() drops the private key; an intermediate needs it back to sign its own children. + using var issued = request.Create(issuer, NotBefore, NotBefore.AddDays(validDays), NextSerial()); + return issued.CopyWithPrivateKey(key); } - private static string WriteTempPemCa() + /// A server (end-entity) certificate signed by . + private static X509Certificate2 IssueLeaf(X509Certificate2 issuer, string commonName, int validDays) + { + using var key = RSA.Create(2048); + var request = new CertificateRequest($"CN={commonName}", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true)); + request.CertificateExtensions.Add( + new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, true)); + request.CertificateExtensions.Add( + new X509EnhancedKeyUsageExtension([new Oid("1.3.6.1.5.5.7.3.1")], false)); // serverAuth + request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); + + return request.Create(issuer, NotBefore, NotBefore.AddDays(validDays), NextSerial()); + } + + /// A self-signed leaf that chains to nothing — the "stranger" a pin must reject. + private static X509Certificate2 SelfSignedLeaf(string commonName) + { + using var key = RSA.Create(2048); + var request = new CertificateRequest($"CN={commonName}", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + return request.CreateSelfSigned(NotBefore, NotBefore.AddDays(20)); + } + + private static byte[] NextSerial() => RandomNumberGenerator.GetBytes(8); + + private static string WritePem(params X509Certificate2[] certificates) { - using var rsa = RSA.Create(2048); - var request = new CertificateRequest("CN=otopcua-test-ca", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); - using var ca = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1)); var path = Path.Combine(Path.GetTempPath(), $"otopcua-mqtt-ca-{Guid.NewGuid():N}.pem"); - File.WriteAllText(path, ca.ExportCertificatePem()); + File.WriteAllText(path, string.Join(Environment.NewLine, certificates.Select(c => c.ExportCertificatePem()))); return path; } + /// + /// Mimics what SslStream hands a validation callback: the presented leaf plus a chain + /// carrying whatever intermediates the peer supplied. The chain is built (and allowed to + /// fail) purely so ChainElements is populated the way the platform would populate it. + /// + private static MqttClientCertificateValidationEventArgs ValidationArgs( + MqttClientOptions built, + X509Certificate2 leaf, + params X509Certificate2[] presentedIntermediates) + { + var chain = new X509Chain(); + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllFlags; + foreach (var intermediate in presentedIntermediates) + { + chain.ChainPolicy.ExtraStore.Add(intermediate); + } + + chain.Build(leaf); + + return new MqttClientCertificateValidationEventArgs( + leaf, + chain, + SslPolicyErrors.RemoteCertificateChainErrors, + built.ChannelOptions); + } + /// /// Accepts the TCP connection and then says nothing — the client sends CONNECT and waits /// forever for a CONNACK that never comes. This is the frozen-peer shape; a closed port is From cacc30a60d9e83fbf28ca946f1d88068189b9a6d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 14:40:15 -0400 Subject: [PATCH 08/43] feat(mqtt): last-value cache backing IReadable (per-ref no-data) LastValueCache bridges MQTT's subscribe-first push model to the OPC UA server's polled IReadable.ReadAsync: the subscription path calls Update() per RawPath, the read path calls Read(). Read never throws; an unseen RawPath returns BadWaitingForInitialData (0x80320000) rather than an exception or null, so a batch covering many references degrades per-ref instead of failing wholesale. Deviates from the plan's GoodNoData snippet: GoodNoData is reserved repo-wide for "the historian window held no samples" (NullHistorianDataSource, OtOpcUaNodeManager HistoryRead paths); BadWaitingForInitialData is the established convention for "no live value observed yet" (CalculationDriver, VirtualTagEngine, FOCAS, AddressSpaceApplier). Keyed by RawPath per the v3 driver-reference identity, not a topic/JSON-path-derived key. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../LastValueCache.cs | 63 +++++++++++++ .../LastValueCacheTests.cs | 88 +++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/LastValueCache.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/LastValueCacheTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/LastValueCache.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/LastValueCache.cs new file mode 100644 index 00000000..f759707f --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/LastValueCache.cs @@ -0,0 +1,63 @@ +using System.Collections.Concurrent; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt; + +/// +/// Thread-safe last-observed-value store backing for MQTT/Sparkplug B. +/// MQTT is a subscribe-first protocol — the driver holds one live broker connection and values +/// arrive pushed, not polled. But the OPC UA server still issues polled IReadable.ReadAsync +/// batches, so this cache is the bridge: the subscription path (message handler) calls +/// with the newest observed value per reference, and the read path serves +/// the most recent one via . +/// +/// +/// Keyed by RawPath — the v3 driver-reference identity (see +/// EquipmentTagRefResolver<TDef> and , +/// which is the RawPath) — never a topic/JSON-path-derived key. The parameter is +/// therefore named rawPath throughout rather than "topic" or "key" to keep that identity +/// fact visible at every call site. +/// +public sealed class LastValueCache +{ + // OPC UA BadWaitingForInitialData (0x80320000) — the repo-wide convention for "no value has + // been observed yet" (mirrored by CalculationDriver before its first evaluation, + // VirtualTagEngine for a freshly-materialised node, and OtOpcUaNodeManager / + // AddressSpaceApplier for a just-deployed variable). MQTT is subscribe-first, so an unseen + // RawPath is in exactly that state until its first publish arrives. Deliberately NOT + // GoodNoData — that code is reserved (see NullHistorianDataSource, OtOpcUaNodeManager + // HistoryRead paths) for "the historian window held no samples", a different question from + // "has this live reference ever been observed". + private const uint BadWaitingForInitialData = 0x80320000u; + + private readonly ConcurrentDictionary _values = new(); + + /// + /// Record the newest observed value for . Called from the MQTT + /// subscription/message-handling path. A null or empty + /// is a no-op — never throws. + /// + /// The RawPath identifying the tag. + /// The newest observed value/quality/timestamps for that tag. + public void Update(string rawPath, DataValueSnapshot snapshot) + { + if (string.IsNullOrEmpty(rawPath)) return; + _values[rawPath] = snapshot; + } + + /// + /// Read the last observed value for . Never throws: a + /// null/empty or a RawPath never observed both return a + /// snapshot rather than an exception, so a batch read + /// covering many references degrades per-reference instead of failing the whole call. + /// + /// The RawPath identifying the tag. + /// The last observed snapshot, or a BadWaitingForInitialData snapshot if none has been observed. + public DataValueSnapshot Read(string rawPath) + { + if (!string.IsNullOrEmpty(rawPath) && _values.TryGetValue(rawPath, out var snapshot)) + return snapshot; + + return new DataValueSnapshot(null, BadWaitingForInitialData, null, DateTime.UtcNow); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/LastValueCacheTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/LastValueCacheTests.cs new file mode 100644 index 00000000..b656fcd5 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/LastValueCacheTests.cs @@ -0,0 +1,88 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests; + +public sealed class LastValueCacheTests +{ + // OPC UA BadWaitingForInitialData (0x80320000) — the repo-wide convention for "no value + // observed yet" (mirrored by CalculationDriver, VirtualTagEngine, FOCAS, and + // OtOpcUaNodeManager for freshly-materialised / not-yet-evaluated nodes). MQTT is + // subscribe-first, so an unseen RawPath is in exactly that state until its first publish. + private const uint BadWaitingForInitialData = 0x80320000u; + + [Fact] + public void Read_UnseenRef_ReturnsPerRefWaitingForInitialData_DoesNotThrow() + { + var cache = new LastValueCache(); + + var snap = cache.Read("factory/oven/temp"); + + snap.StatusCode.ShouldBe(BadWaitingForInitialData); + snap.Value.ShouldBeNull(); + } + + [Fact] + public void Update_ThenRead_ReturnsLastValue() + { + var cache = new LastValueCache(); + var now = DateTime.UtcNow; + + cache.Update("factory/oven/temp", new DataValueSnapshot(42.0, 0u, now, now)); + + var snap = cache.Read("factory/oven/temp"); + snap.Value.ShouldBe(42.0); + snap.StatusCode.ShouldBe(0u); + } + + [Fact] + public void Update_OverwritesPreviousValue_ForSameRawPath() + { + var cache = new LastValueCache(); + var t1 = DateTime.UtcNow; + var t2 = t1.AddSeconds(1); + + cache.Update("factory/oven/temp", new DataValueSnapshot(1.0, 0u, t1, t1)); + cache.Update("factory/oven/temp", new DataValueSnapshot(2.0, 0u, t2, t2)); + + cache.Read("factory/oven/temp").Value.ShouldBe(2.0); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void Read_NullOrEmptyRawPath_DoesNotThrow_ReturnsWaitingForInitialData(string? rawPath) + { + var cache = new LastValueCache(); + + var snap = cache.Read(rawPath!); + + snap.StatusCode.ShouldBe(BadWaitingForInitialData); + } + + [Fact] + public void Update_NullRawPath_DoesNotThrow() + { + var cache = new LastValueCache(); + + Should.NotThrow(() => cache.Update(null!, new DataValueSnapshot(1.0, 0u, DateTime.UtcNow, DateTime.UtcNow))); + } + + [Fact] + public void Read_DistinguishesUnseenFromGenuinelyBadObservedValue() + { + var cache = new LastValueCache(); + var now = DateTime.UtcNow; + const uint badDeviceFailure = 0x808B0000u; + + cache.Update("factory/oven/temp", new DataValueSnapshot(null, badDeviceFailure, null, now)); + + var observedBad = cache.Read("factory/oven/temp"); + var neverObserved = cache.Read("factory/oven/other"); + + observedBad.StatusCode.ShouldBe(badDeviceFailure); + neverObserved.StatusCode.ShouldBe(BadWaitingForInitialData); + observedBad.StatusCode.ShouldNotBe(neverObserved.StatusCode); + } +} From 768fd87774ab5b7866d1696eeb75172262482233 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 14:58:37 -0400 Subject: [PATCH 09/43] feat(mqtt): hand-rolled reconnect loop with bounded backoff + resubscribe Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../MqttConnection.cs | 675 +++++++++++++++--- .../MqttConnectionTests.cs | 513 +++++++++++++ 2 files changed, 1101 insertions(+), 87 deletions(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs index 1a6e9072..d01d2444 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs @@ -6,17 +6,43 @@ using MQTTnet; namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt; +/// Lifecycle state of an . +public enum MqttConnectionState +{ + /// Constructed, or the first connect has not yet succeeded. No supervisor is running. + Disconnected = 0, + + /// An MQTT session is established. + Connected = 1, + + /// + /// The session dropped and the supervisor is retrying under backoff. A broker that is merely + /// down stays here indefinitely — being unreachable is never a fault. + /// + Reconnecting = 2, + + /// + /// Unrecoverable configuration: retrying cannot help, so the supervisor stops. Only + /// failures raised while assembling the client options (before any I/O) land here. + /// + Faulted = 3, + + /// has run. Terminal. + Disposed = 4, +} + /// /// Owns the driver's single live MQTTnet-5 client: assembles the broker connection options /// from (endpoint, protocol version, session, credentials, -/// TLS + CA pin) and connects under a bounded deadline. +/// TLS + CA pin), connects under a bounded deadline, and keeps the session alive with a +/// hand-rolled reconnect supervisor. /// /// /// /// Connection-free construction. The constructor stores configuration only — it -/// opens no socket and creates no client. The universal-browser CanBrowse pattern -/// constructs a throwaway instance purely to ask what driver type it is, so a -/// constructor that dialled a broker would stall the AdminUI. +/// opens no socket, creates no client and starts no supervisor. The universal-browser +/// CanBrowse pattern constructs a throwaway instance purely to ask what driver type +/// it is, so a constructor that dialled a broker would stall the AdminUI. /// /// /// Bounded connect. links the caller's token with a @@ -24,6 +50,26 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt; /// fed to MQTTnet's own . A broker that accepts the /// TCP connection and then never answers CONNACK — the frozen-peer shape that wedged the /// S7 read path (arch-review R2-01) — fails at the deadline instead of hanging forever. +/// Every wait in this type is bounded the same way, teardown included. +/// +/// +/// Hand-rolled reconnect. MQTTnet v5 dropped v4's ManagedMqttClient, so there +/// is no library-managed reconnect: this type supplies it. The supervisor starts on the +/// first successful connect (a failed first attempt is the caller's to retry — the +/// driver-host resilience layer re-runs InitializeAsync — and must not leave a +/// background task hammering an endpoint the caller has given up on). MQTTnet's +/// DisconnectedAsync callback only signals a semaphore and returns, so the library's +/// own dispatcher thread is never blocked by a backoff sleep. +/// +/// +/// is load-bearing. MQTT subscriptions do not survive a +/// clean session, and a reconnect that completes without re-subscribing produces a +/// healthy-looking connection that receives nothing, forever, with no error, no exception +/// and no bad status. So the callback fires on every successful reconnect — including +/// persistent-session reconnects, where re-subscribing is merely redundant. Re-subscribing +/// an already-subscribed topic is harmless; missing one is silent death. If the callback +/// fails, the freshly-established session is torn down and retried rather than left +/// connected-but-deaf. /// /// /// Credentials never leak. Nothing here logs or formats @@ -31,29 +77,49 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt; /// ToString(). /// /// -/// NOT thread-safe — single-caller by contract. and -/// take no lock and must never run concurrently. Calling them -/// concurrently leaks a live broker connection: can -/// observe _disposed == false, set it, exchange a still-null _client for -/// null, and return having disposed nothing — after which the in-flight -/// creates a client, assigns it and connects successfully. -/// That client holds an open socket that no later can ever -/// reach, because _disposed is already true. Serialising the lifecycle is -/// the job of the reconnect supervisor added in Task 4; it is deliberately not solved -/// here. +/// Lifecycle is serialised. Connect (caller-initiated or supervisor-initiated) and +/// dispose run under a single gate, and a connect that completes re-checks the disposed flag +/// while still holding that gate. This closes the connection leak the Task-3 review +/// traced: dispose could observe a still-null client, dispose nothing and return, after +/// which the in-flight connect assigned a client and connected successfully — leaving a live +/// socket no later dispose could ever reach. Now the losing side of that race disposes the +/// client it created and reports . /// -/// Reconnect/backoff (Task 4) and subscription (Task 6) are deliberately not implemented here. +/// Subscription (Task 6) and Sparkplug rebirth-on-reconnect (Task 21, which hangs off +/// ) are deliberately not implemented here. /// public sealed class MqttConnection : IAsyncDisposable { private readonly string _driverId; + + /// + /// Serialises connect (both callers of it) against dispose. Never disposed: a late MQTTnet + /// callback can still reach this instance after teardown, and a disposed + /// would throw inside the library's own dispatcher. + /// + private readonly SemaphoreSlim _lifecycleGate = new(1, 1); + + /// Cancelled by ; aborts the supervisor and any in-flight connect. + private readonly CancellationTokenSource _lifetimeCts = new(); + private readonly ILogger? _logger; private readonly MqttDriverOptions _options; - private IMqttClient? _client; - private bool _disposed; + /// + /// Wakes the supervisor. Unbounded max count: spurious releases (a failed connect attempt + /// also raises DisconnectedAsync) are drained harmlessly by the still-connected guard, + /// whereas a bounded semaphore would throw on the extra release. Never disposed, for the same + /// reason as . + /// + private readonly SemaphoreSlim _reconnectWake = new(0); - /// Stores configuration only — no network, no client instantiation. + private IMqttClient? _client; + private int _disposed; + private long _lastMessageTicksUtc; + private Task? _reconnectWorker; + private int _state = (int)MqttConnectionState.Disconnected; + + /// Stores configuration only — no network, no client instantiation, no supervisor. /// Broker connection settings. /// Driver instance id, used only for log/diagnostic correlation. /// Optional logger; never receives credentials. @@ -67,25 +133,520 @@ public sealed class MqttConnection : IAsyncDisposable _logger = logger; } + /// + /// Raised after every successful reconnect (never after the initial + /// , which the caller already sequences its own subscribe behind). + /// Subscribers must re-establish their MQTT subscriptions here — see the type remarks for + /// why skipping it is silent death. Every subscriber is invoked even if an earlier one + /// throws; a throwing subscriber causes the session to be torn down and retried. + /// + public event Func? Reconnected; + /// Whether the underlying client currently holds an established MQTT session. public bool IsConnected => _client?.IsConnected ?? false; + /// + /// UTC timestamp of the most recent inbound application message, or null if none has + /// arrived on this instance. Connection health only — nothing here routes or parses the + /// message; that is the subscription manager's job (Task 6). + /// + /// + /// The plan calls this member LastMessageAgeUtc, which conflates a timestamp with an + /// age; it is split here into the instant () and the elapsed span + /// (). + /// + public DateTime? LastMessageUtc + { + get + { + var ticks = Interlocked.Read(ref _lastMessageTicksUtc); + return ticks == 0 ? null : new DateTime(ticks, DateTimeKind.Utc); + } + } + + /// + /// How long ago the most recent inbound application message arrived, or null if none + /// has. A connection that is with a large age is + /// the "reconnected but never re-subscribed" shape. + /// + public TimeSpan? LastMessageAge => LastMessageUtc is { } at ? DateTime.UtcNow - at : null; + + /// Current lifecycle state. See . + public MqttConnectionState State => (MqttConnectionState)Volatile.Read(ref _state); + + /// + /// Test seam: awaited inside the gated connect immediately after the broker session is + /// established and before the disposed re-check, so a test can reproduce the exact + /// connect/dispose interleaving that used to leak a live connection. Always null in + /// production. + /// + internal Func? AfterConnectHookForTests { get; set; } + + private bool Disposed => Volatile.Read(ref _disposed) != 0; + /// public async ValueTask DisposeAsync() { - if (_disposed) + if (Interlocked.Exchange(ref _disposed, 1) != 0) { return; } - _disposed = true; + SetState(MqttConnectionState.Disposed); - var client = Interlocked.Exchange(ref _client, null); + // Stop the supervisor and abort any in-flight connect FIRST, so the gate below is not held + // for a full connect deadline by work that is already pointless. + try + { + await _lifetimeCts.CancelAsync().ConfigureAwait(false); + } + catch (ObjectDisposedException) + { + // Nothing to cancel. + } + + var worker = Volatile.Read(ref _reconnectWorker); + if (worker is not null) + { + try + { + await worker.WaitAsync(TimeSpan.FromSeconds(_options.ConnectTimeoutSeconds)).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger?.LogDebug(ex, "MQTT driver '{DriverId}': reconnect supervisor did not stop cleanly.", _driverId); + } + } + + // Bounded even here: a wedged connect must not turn dispose into a hang. If the gate cannot + // be taken we still claim the client — double-disposing an MqttClient is harmless, leaking a + // live one is not. + var gated = await _lifecycleGate + .WaitAsync(TimeSpan.FromSeconds(_options.ConnectTimeoutSeconds)) + .ConfigureAwait(false); + if (!gated) + { + _logger?.LogWarning( + "MQTT driver '{DriverId}': lifecycle gate still held at dispose; tearing the client down anyway.", + _driverId); + } + + try + { + var client = Interlocked.Exchange(ref _client, null); + if (client is not null) + { + await CloseAsync(client).ConfigureAwait(false); + } + } + finally + { + if (gated) + { + _lifecycleGate.Release(); + } + } + } + + /// + /// The backoff a supervisor waits before reconnect attempt : + /// minSeconds × 2^attempt, saturated at . Pure — no + /// clock, no state, no jitter (deliberately: the schedule is asserted by exact equality, and + /// with one connection per driver instance there is no thundering herd to spread out). + /// + /// + /// Computed in rather than by shifting, because min << attempt + /// wraps — 1 << 32 is 1 — which would silently turn a long outage back + /// into a once-a-second hammering of a dead broker. A maxSeconds below + /// is misconfiguration; the floor wins, because the floor + /// is what stops the loop going hot. + /// + /// Zero-based attempt index; negatives are treated as the first attempt. + /// Delay before the first attempt, and the floor for every later one. + /// Cap on the exponential growth. + public static TimeSpan NextBackoff(int attempt, int minSeconds, int maxSeconds) + { + var min = Math.Max(1d, minSeconds); + var max = Math.Max(min, maxSeconds); + + if (attempt <= 0) + { + return TimeSpan.FromSeconds(min); + } + + // Math.Pow saturates to +Infinity instead of wrapping, so a huge attempt count clamps to max. + var seconds = min * Math.Pow(2d, attempt); + return TimeSpan.FromSeconds(seconds >= max || double.IsNaN(seconds) ? max : seconds); + } + + /// + /// Connects to the broker, failing at + /// rather than waiting indefinitely on an unresponsive peer. On success the reconnect + /// supervisor takes over keeping the session alive. + /// + /// + /// May be retried on the same instance after a failed attempt — the underlying + /// is created once and reused across attempts. Safe to call + /// concurrently with : the two are serialised, and a connect that + /// loses the race disposes whatever it built and throws + /// . + /// + /// Caller cancellation; linked with the connect deadline. + /// The connect deadline elapsed. + /// was cancelled. + /// + /// The connection was disposed, either before the attempt started or while it was in flight. + /// + public Task ConnectAsync(CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(Disposed, this); + + return ConnectCoreAsync(startSupervisor: true, cancellationToken); + } + + /// + /// Invokes every subscriber. Walks the invocation list explicitly: + /// awaiting a multicast directly yields only the last + /// subscriber's task and abandons the earlier ones, so a single throwing subscriber would + /// silently skip every subscriber behind it — and a skipped re-subscribe is a topic that goes + /// dark. Failures are collected and rethrown after every subscriber has had its turn. + /// + internal async Task FireReconnectedAsync() + { + var subscribers = Reconnected; + if (subscribers is null) + { + return; + } + + List? failures = null; + foreach (var subscriber in subscribers.GetInvocationList().Cast>()) + { + try + { + await subscriber().ConfigureAwait(false); + } + catch (Exception ex) + { + (failures ??= []).Add(ex); + } + } + + if (failures is not null) + { + throw new AggregateException( + $"MQTT driver '{_driverId}': {failures.Count} re-subscribe callback(s) failed after reconnect.", + failures); + } + } + + private async Task ConnectCoreAsync(bool startSupervisor, CancellationToken cancellationToken) + { + // The options are rebuilt per attempt so a rotated CA file is picked up on the next connect + // rather than being pinned for the life of the process. This is also the only step that can + // fail unrecoverably — it is pure config assembly, so retrying it can never help. + MqttClientOptions clientOptions; + try + { + clientOptions = BuildClientOptions(_options, clientIdSuffix: null, _logger); + } + catch (Exception ex) + { + SetState(MqttConnectionState.Faulted); + _logger?.LogError( + ex, + "MQTT driver '{DriverId}': broker configuration is unusable; no amount of retrying will help.", + _driverId); + throw; + } + + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(_options.ConnectTimeoutSeconds)); + using var linked = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + deadline.Token, + _lifetimeCts.Token); + + _logger?.LogDebug( + "MQTT driver '{DriverId}': connecting to {Host}:{Port} (tls={UseTls}, timeout={TimeoutSeconds}s).", + _driverId, + _options.Host, + _options.Port, + _options.UseTls, + _options.ConnectTimeoutSeconds); + + try + { + await _lifecycleGate.WaitAsync(linked.Token).ConfigureAwait(false); + } + catch (Exception ex) + { + throw Classify(ex, cancellationToken, deadline); + } + + try + { + // Dispose won the race outright: it has already flagged the instance and disposed + // whatever existed. Creating a client now would be creating one nobody can reach. + ObjectDisposedException.ThrowIf(Disposed, this); + + var client = _client; + if (client is null) + { + client = new MqttClientFactory().CreateMqttClient(); + AttachHandlers(client); + _client = client; + } + + await client.ConnectAsync(clientOptions, linked.Token).ConfigureAwait(false); + + if (AfterConnectHookForTests is { } hook) + { + await hook().ConfigureAwait(false); + } + + // The race the Task-3 review traced: dispose flagged the instance while this connect was + // in flight, then blocked on the very gate this call holds — so it disposed nothing and + // the session below would have been unreachable forever. Hand it back here instead. + if (Disposed) + { + Interlocked.CompareExchange(ref _client, null, client); + await CloseAsync(client).ConfigureAwait(false); + throw new ObjectDisposedException( + nameof(MqttConnection), + $"MQTT driver '{_driverId}': the connection was disposed while a connect to " + + $"{_options.Host}:{_options.Port} was in flight; the established session was torn down."); + } + + SetState(MqttConnectionState.Connected); + + if (startSupervisor) + { + _reconnectWorker ??= Task.Run(() => ReconnectSupervisorAsync(_lifetimeCts.Token)); + } + } + catch (ObjectDisposedException) + { + throw; + } + catch (Exception ex) + { + throw Classify(ex, cancellationToken, deadline); + } + finally + { + _lifecycleGate.Release(); + } + } + + /// + /// Maps a connect failure onto this type's documented contract. MQTTnet wraps a cancelled + /// connect in MqttConnectingFailedException rather than letting the + /// surface, so the legs are told apart by which + /// token fired, not by exception type. Precedence: teardown, then caller intent, then the + /// deadline. + /// + private Exception Classify(Exception ex, CancellationToken cancellationToken, CancellationTokenSource deadline) + { + if (Disposed) + { + return new ObjectDisposedException( + nameof(MqttConnection), + new InvalidOperationException( + $"MQTT driver '{_driverId}': connect to {_options.Host}:{_options.Port} was aborted because the " + + "connection was disposed while the attempt was in flight.", + ex)); + } + + if (cancellationToken.IsCancellationRequested) + { + return new OperationCanceledException( + $"MQTT driver '{_driverId}': connect to {_options.Host}:{_options.Port} was cancelled.", + ex, + cancellationToken); + } + + if (deadline.IsCancellationRequested) + { + return new TimeoutException( + $"MQTT driver '{_driverId}': connect to {_options.Host}:{_options.Port} did not complete within " + + $"{_options.ConnectTimeoutSeconds}s.", + ex); + } + + return ex; + } + + private void AttachHandlers(IMqttClient client) + { + client.DisconnectedAsync += OnDisconnectedAsync; + client.ApplicationMessageReceivedAsync += OnApplicationMessageReceivedAsync; + } + + /// + /// Runs on MQTTnet's own dispatcher, so it does exactly two non-blocking things and returns. + /// Sleeping the backoff here would stall the client's internal pump. + /// + private Task OnDisconnectedAsync(MqttClientDisconnectedEventArgs args) + { + if (Disposed || State is MqttConnectionState.Faulted or MqttConnectionState.Disposed) + { + return Task.CompletedTask; + } + + if (State == MqttConnectionState.Connected) + { + SetState(MqttConnectionState.Reconnecting); + } + + // Released unconditionally rather than only when the client had been connected: a missed + // wake is a permanently dark connection, whereas a spurious one costs a loop iteration. + _reconnectWake.Release(); + return Task.CompletedTask; + } + + private Task OnApplicationMessageReceivedAsync(MqttApplicationMessageReceivedEventArgs args) + { + Interlocked.Exchange(ref _lastMessageTicksUtc, DateTime.UtcNow.Ticks); + return Task.CompletedTask; + } + + /// + /// The reconnect loop MQTTnet v5 no longer provides. Started on the first successful connect + /// and stopped by cancelling the lifetime token; it owns every + /// reconnect attempt so the library's dispatcher never waits on one. + /// + private async Task ReconnectSupervisorAsync(CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + await _reconnectWake.WaitAsync(cancellationToken).ConfigureAwait(false); + + if (Disposed || State == MqttConnectionState.Faulted) + { + return; + } + + if (IsConnected) + { + continue; // Spurious wake (e.g. a failed attempt's own disconnect callback). + } + + SetState(MqttConnectionState.Reconnecting); + + for (var attempt = 0; !cancellationToken.IsCancellationRequested && !Disposed; attempt++) + { + await Task + .Delay( + NextBackoff( + attempt, + _options.ReconnectMinBackoffSeconds, + _options.ReconnectMaxBackoffSeconds), + cancellationToken) + .ConfigureAwait(false); + + try + { + await ConnectCoreAsync(startSupervisor: false, cancellationToken).ConfigureAwait(false); + } + catch (ObjectDisposedException) + { + return; + } + catch (Exception ex) + { + if (State == MqttConnectionState.Faulted) + { + _logger?.LogError( + ex, + "MQTT driver '{DriverId}': reconnect abandoned — the broker configuration is unusable.", + _driverId); + return; + } + + _logger?.LogDebug( + ex, + "MQTT driver '{DriverId}': reconnect attempt {Attempt} to {Host}:{Port} failed.", + _driverId, + attempt + 1, + _options.Host, + _options.Port); + continue; + } + + try + { + // ALWAYS, on every reconnect — including persistent sessions, where this is + // merely redundant. Subscriptions do not survive a clean session, and a + // reconnect that skips this leaves a healthy-looking, permanently deaf client. + await FireReconnectedAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + _logger?.LogError( + ex, + "MQTT driver '{DriverId}': re-subscribe after reconnect failed; tearing the session down " + + "and retrying rather than serving a connection that receives nothing.", + _driverId); + SetState(MqttConnectionState.Reconnecting); + await ForceDisconnectAsync().ConfigureAwait(false); + continue; + } + + _logger?.LogInformation( + "MQTT driver '{DriverId}': reconnected to {Host}:{Port} after {Attempts} attempt(s); " + + "subscriptions re-established.", + _driverId, + _options.Host, + _options.Port, + attempt + 1); + break; + } + } + } + catch (OperationCanceledException) + { + // Dispose cancelled the lifetime token — the expected way this loop ends. + } + catch (Exception ex) + { + // Reaching here means the connection is permanently dark, which is exactly the failure + // this supervisor exists to prevent — it must never be swallowed quietly. + _logger?.LogError( + ex, + "MQTT driver '{DriverId}': reconnect supervisor stopped unexpectedly; the broker session will not " + + "recover without a driver restart.", + _driverId); + } + } + + /// + /// Drops a session that is connected but unusable, so the next supervisor pass starts from a + /// clean CONNECT. Best-effort and bounded — failure here just means the next attempt sees a + /// client that is already down. + /// + private async Task ForceDisconnectAsync() + { + var client = _client; if (client is null) { return; } + try + { + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(_options.ConnectTimeoutSeconds)); + await client.DisconnectAsync(new MqttClientDisconnectOptions(), deadline.Token).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger?.LogDebug(ex, "MQTT driver '{DriverId}': forced disconnect failed.", _driverId); + } + } + + private async Task CloseAsync(IMqttClient client) + { try { if (client.IsConnected) @@ -105,75 +666,15 @@ public sealed class MqttConnection : IAsyncDisposable } } - /// - /// Connects to the broker, failing at - /// rather than waiting indefinitely on an unresponsive peer. - /// - /// - /// May be retried on the same instance after a failed attempt — the underlying - /// is created once and reused across attempts, which is what the - /// Task-4 reconnect loop depends on. Not safe to call concurrently with itself or with - /// ; see the type-level remarks. - /// - /// Caller cancellation; linked with the connect deadline. - /// The connect deadline elapsed. - /// was cancelled. - /// - /// The connection was disposed, either before the attempt started or while it was in flight. - /// - public async Task ConnectAsync(CancellationToken cancellationToken) + /// is terminal — nothing may move off it. + private void SetState(MqttConnectionState state) { - ObjectDisposedException.ThrowIf(_disposed, this); - - // The options are rebuilt per attempt so a rotated CA file is picked up on the next - // connect rather than being pinned for the life of the process. - var client = _client ??= new MqttClientFactory().CreateMqttClient(); - var clientOptions = BuildClientOptions(_options, clientIdSuffix: null, _logger); - - using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(_options.ConnectTimeoutSeconds)); - using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, deadline.Token); - - _logger?.LogDebug( - "MQTT driver '{DriverId}': connecting to {Host}:{Port} (tls={UseTls}, timeout={TimeoutSeconds}s).", - _driverId, - _options.Host, - _options.Port, - _options.UseTls, - _options.ConnectTimeoutSeconds); - - try + if (Volatile.Read(ref _state) == (int)MqttConnectionState.Disposed) { - await client.ConnectAsync(clientOptions, linked.Token).ConfigureAwait(false); - } - // A concurrent DisposeAsync disposes the client out from under the awaiting connect, and - // whatever MQTTnet throws for that matches neither token filter. Classify it rather than - // letting an undocumented exception escape the contract below. - catch (Exception ex) when (_disposed) - { - throw new ObjectDisposedException( - nameof(MqttConnection), - new InvalidOperationException( - $"MQTT driver '{_driverId}': connect to {_options.Host}:{_options.Port} was aborted because the " - + "connection was disposed while the attempt was in flight.", - ex)); - } - // MQTTnet wraps a cancelled connect in MqttConnectingFailedException rather than letting - // the OperationCanceledException surface, so the two legs are told apart by which token - // fired, not by the exception type. Caller intent wins over the deadline. - catch (Exception ex) when (cancellationToken.IsCancellationRequested) - { - throw new OperationCanceledException( - $"MQTT driver '{_driverId}': connect to {_options.Host}:{_options.Port} was cancelled.", - ex, - cancellationToken); - } - catch (Exception ex) when (deadline.IsCancellationRequested) - { - throw new TimeoutException( - $"MQTT driver '{_driverId}': connect to {_options.Host}:{_options.Port} did not complete within " - + $"{_options.ConnectTimeoutSeconds}s.", - ex); + return; } + + Volatile.Write(ref _state, (int)state); } /// diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttConnectionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttConnectionTests.cs index c95cb714..b4b5a3b9 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttConnectionTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttConnectionTests.cs @@ -376,10 +376,366 @@ public sealed class MqttConnectionTests conn.IsConnected.ShouldBeFalse(); } + // --------------------------------------------------------------------------------- + // Task 4 — NextBackoff (pure): bounded, monotone, cannot hot-loop, cannot overflow + // --------------------------------------------------------------------------------- + + [Theory] + [InlineData(0, 1)] + [InlineData(1, 2)] + [InlineData(2, 4)] + [InlineData(3, 8)] + [InlineData(4, 16)] + [InlineData(5, 30)] // 32 → clamped + [InlineData(10, 30)] + public void NextBackoff_IsExponentialClampedToMax(int attempt, int expectedSeconds) + => MqttConnection.NextBackoff(attempt, minSeconds: 1, maxSeconds: 30) + .ShouldBe(TimeSpan.FromSeconds(expectedSeconds)); + + /// + /// A shift-based implementation (min << attempt) wraps: 1 << 32 is + /// 1, not 4294967296, so a broker that has been down for half an hour would suddenly + /// be hammered once a second. The calculator must saturate, not wrap. + /// + [Fact] + public void NextBackoff_HugeAttemptCount_SaturatesAtMax_NeverWrapsBackToMin() + { + foreach (var attempt in new[] { 31, 32, 33, 40, 62, 63, 64, 1_000, int.MaxValue }) + { + MqttConnection.NextBackoff(attempt, minSeconds: 1, maxSeconds: 30) + .ShouldBe(TimeSpan.FromSeconds(30), $"attempt {attempt}"); + } + } + + [Fact] + public void NextBackoff_IsMonotoneNonDecreasing_AndAlwaysWithinMinMax() + { + var previous = TimeSpan.Zero; + for (var attempt = 0; attempt <= 64; attempt++) + { + var backoff = MqttConnection.NextBackoff(attempt, minSeconds: 2, maxSeconds: 30); + + backoff.ShouldBeGreaterThanOrEqualTo(previous, $"attempt {attempt} went backwards"); + backoff.ShouldBeGreaterThanOrEqualTo(TimeSpan.FromSeconds(2), $"attempt {attempt} below min"); + backoff.ShouldBeLessThanOrEqualTo(TimeSpan.FromSeconds(30), $"attempt {attempt} above max"); + previous = backoff; + } + } + + /// + /// A misconfigured max < min must not collapse the floor — the floor is the only + /// thing standing between a dead broker and a hot reconnect loop. + /// + [Fact] + public void NextBackoff_MaxBelowMin_HonoursMin_SoTheLoopCannotGoHot() + => MqttConnection.NextBackoff(0, minSeconds: 10, maxSeconds: 5).ShouldBe(TimeSpan.FromSeconds(10)); + + [Fact] + public void NextBackoff_NonPositiveAttempt_IsTheFirstAttemptDelay() + { + MqttConnection.NextBackoff(0, minSeconds: 3, maxSeconds: 30).ShouldBe(TimeSpan.FromSeconds(3)); + MqttConnection.NextBackoff(-5, minSeconds: 3, maxSeconds: 30).ShouldBe(TimeSpan.FromSeconds(3)); + } + + // --------------------------------------------------------------------------------- + // Task 4 — the Reconnected callback (the load-bearing re-subscribe hook) + // --------------------------------------------------------------------------------- + + [Fact] + public async Task FireReconnected_InvokesEverySubscribedCallback() + { + await using var conn = new MqttConnection(LoopbackOpts(), driverId: "t", logger: null); + var first = 0; + var second = 0; + conn.Reconnected += () => { Interlocked.Increment(ref first); return Task.CompletedTask; }; + conn.Reconnected += () => { Interlocked.Increment(ref second); return Task.CompletedTask; }; + + await conn.FireReconnectedAsync(); + + first.ShouldBe(1); + second.ShouldBe(1); + } + + /// + /// Awaiting a multicast Func<Task> directly returns only the LAST handler's task + /// and abandons the earlier ones — so one throwing subscriber would silently skip every + /// subscriber behind it. The fan-out must walk the invocation list and still surface the + /// failure, because a re-subscribe that failed is a connection that has gone dark. + /// + [Fact] + public async Task FireReconnected_OneHandlerThrows_StillInvokesTheRest_AndSurfacesTheFailure() + { + await using var conn = new MqttConnection(LoopbackOpts(), driverId: "t", logger: null); + var behindTheThrower = 0; + conn.Reconnected += () => throw new InvalidOperationException("SUBACK refused"); + conn.Reconnected += () => { Interlocked.Increment(ref behindTheThrower); return Task.CompletedTask; }; + + await Should.ThrowAsync(async () => await conn.FireReconnectedAsync()); + + behindTheThrower.ShouldBe(1); + } + + [Fact] + public async Task FireReconnected_NoSubscribers_IsANoOp() + { + await using var conn = new MqttConnection(LoopbackOpts(), driverId: "t", logger: null); + + await Should.NotThrowAsync(async () => await conn.FireReconnectedAsync()); + } + + // --------------------------------------------------------------------------------- + // Task 4 — State + LastMessage + // --------------------------------------------------------------------------------- + + [Fact] + public void State_AfterCtor_IsDisconnected() + => new MqttConnection(LoopbackOpts(), driverId: "t", logger: null) + .State.ShouldBe(MqttConnectionState.Disconnected); + + [Fact] + public async Task State_AfterDispose_IsDisposed() + { + var conn = new MqttConnection(LoopbackOpts(), driverId: "t", logger: null); + + await conn.DisposeAsync(); + + conn.State.ShouldBe(MqttConnectionState.Disposed); + } + + /// + /// Faulted is reserved for config that cannot succeed no matter how long we retry. A broker + /// that is merely down is , indefinitely. + /// + [Fact] + public async Task State_UnrecoverableProtocolConfig_IsFaulted() + { + var opts = new MqttDriverOptions + { + Host = "127.0.0.1", + Port = 1, + UseTls = false, + ConnectTimeoutSeconds = 1, + ProtocolVersion = (MqttProtocolVersion)99, + }; + await using var conn = new MqttConnection(opts, driverId: "t", logger: null); + + await Should.ThrowAsync(async () => await conn.ConnectAsync(CancellationToken.None)); + + conn.State.ShouldBe(MqttConnectionState.Faulted); + } + + /// A broker that is simply unreachable is retryable, never Faulted. + [Fact] + public async Task State_UnreachableBroker_IsNotFaulted() + { + using var blackhole = new BlackholeBroker(); + var opts = new MqttDriverOptions + { + Host = "127.0.0.1", Port = blackhole.Port, UseTls = false, ConnectTimeoutSeconds = 1, + }; + await using var conn = new MqttConnection(opts, driverId: "t", logger: null); + + await Should.ThrowAsync(async () => await conn.ConnectAsync(CancellationToken.None)); + + conn.State.ShouldNotBe(MqttConnectionState.Faulted); + } + + [Fact] + public void LastMessage_BeforeAnyTraffic_IsNull() + { + var conn = new MqttConnection(LoopbackOpts(), driverId: "t", logger: null); + + conn.LastMessageUtc.ShouldBeNull(); + conn.LastMessageAge.ShouldBeNull(); + } + + /// + /// A failed FIRST connect is the caller's problem (driver-host resilience retries + /// InitializeAsync) — it must not silently leave a background reconnect worker + /// hammering an endpoint the caller has already given up on. + /// + [Fact] + public async Task ConnectAsync_InitialAttemptFails_LeavesNoBackgroundReconnectStorm() + { + using var blackhole = new BlackholeBroker(); + var opts = new MqttDriverOptions + { + Host = "127.0.0.1", + Port = blackhole.Port, + UseTls = false, + ConnectTimeoutSeconds = 1, + ReconnectMinBackoffSeconds = 1, + ReconnectMaxBackoffSeconds = 1, + }; + await using var conn = new MqttConnection(opts, driverId: "t", logger: null); + + await Should.ThrowAsync(async () => await conn.ConnectAsync(CancellationToken.None)); + var acceptedAfterTheAttempt = blackhole.AcceptedCount; + + await Task.Delay(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); + + blackhole.AcceptedCount.ShouldBe(acceptedAfterTheAttempt); + } + + // --------------------------------------------------------------------------------- + // Task 4 — the reconnect supervisor, against a real (minimal) broker + // --------------------------------------------------------------------------------- + + /// + /// THE load-bearing invariant. MQTT subscriptions do not survive a clean session, so a + /// reconnect that completes without firing Reconnected leaves a healthy-looking + /// connection that receives nothing, forever, with no error to show for it. Driven through + /// the real socket → real MQTTnet DisconnectedAsync → real re-CONNECT path, not a + /// test seam. + /// + [Fact] + public async Task BrokerDropsTheConnection_ReconnectsAndFiresReconnected() + { + using var broker = new MiniBroker(); + await using var conn = new MqttConnection(BrokerOpts(broker.Port), driverId: "t", logger: null); + var resubscribes = 0; + conn.Reconnected += () => { Interlocked.Increment(ref resubscribes); return Task.CompletedTask; }; + + await conn.ConnectAsync(CancellationToken.None); + + conn.State.ShouldBe(MqttConnectionState.Connected); + conn.IsConnected.ShouldBeTrue(); + Volatile.Read(ref resubscribes).ShouldBe(0, "the initial connect is not a reconnect"); + + broker.DropAll(); + + (await WaitUntilAsync(() => Volatile.Read(ref resubscribes) >= 1, TimeSpan.FromSeconds(30))) + .ShouldBeTrue("the reconnect never fired Reconnected — subscriptions would be silently gone"); + (await WaitUntilAsync(() => conn.IsConnected, TimeSpan.FromSeconds(10))).ShouldBeTrue(); + conn.State.ShouldBe(MqttConnectionState.Connected); + broker.AcceptedCount.ShouldBeGreaterThanOrEqualTo(2); + } + + /// + /// A broker that stays down keeps the supervisor in Reconnecting indefinitely (never + /// Faulted) — and the backoff keeps the retry rate low enough that a handful of + /// attempts, not hundreds, land in the observation window. + /// + [Fact] + public async Task BrokerStaysDown_RetriesUnderBackoff_WithoutHotLooping() + { + using var broker = new MiniBroker(); + var opts = BrokerOpts(broker.Port) with + { + ConnectTimeoutSeconds = 1, ReconnectMinBackoffSeconds = 1, ReconnectMaxBackoffSeconds = 2, + }; + await using var conn = new MqttConnection(opts, driverId: "t", logger: null); + + await conn.ConnectAsync(CancellationToken.None); + var acceptedWhileHealthy = broker.AcceptedCount; + + broker.Blackhole = true; // accepts TCP, never answers CONNACK again + broker.DropAll(); + + (await WaitUntilAsync(() => conn.State == MqttConnectionState.Reconnecting, TimeSpan.FromSeconds(10))) + .ShouldBeTrue(); + await Task.Delay(TimeSpan.FromSeconds(8), TestContext.Current.CancellationToken); + + conn.State.ShouldBe(MqttConnectionState.Reconnecting, "a down broker is retryable, not Faulted"); + var retries = broker.AcceptedCount - acceptedWhileHealthy; + retries.ShouldBeGreaterThanOrEqualTo(1, "the supervisor gave up on a down broker"); + retries.ShouldBeLessThan(15, $"backoff is not bounding the retry rate ({retries} attempts in ~8s)"); + } + + /// + /// Dispose during a reconnect backoff must stop the supervisor dead — no leaked task, no + /// later attempt resurrecting a connection the caller has torn down. + /// + [Fact] + public async Task Dispose_DuringReconnectBackoff_StopsTheSupervisor_NoFurtherAttempts() + { + using var broker = new MiniBroker(); + var opts = BrokerOpts(broker.Port) with + { + ConnectTimeoutSeconds = 1, ReconnectMinBackoffSeconds = 1, ReconnectMaxBackoffSeconds = 2, + }; + var conn = new MqttConnection(opts, driverId: "t", logger: null); + + await conn.ConnectAsync(CancellationToken.None); + broker.Blackhole = true; + broker.DropAll(); + (await WaitUntilAsync(() => conn.State == MqttConnectionState.Reconnecting, TimeSpan.FromSeconds(10))) + .ShouldBeTrue(); + + await conn.DisposeAsync(); + var acceptedAtDispose = broker.AcceptedCount; + + conn.State.ShouldBe(MqttConnectionState.Disposed); + conn.IsConnected.ShouldBeFalse(); + await Task.Delay(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + broker.AcceptedCount.ShouldBe(acceptedAtDispose, "the supervisor outlived DisposeAsync"); + } + + /// + /// The T3 connect/dispose leak, reproduced at the exact interleaving the reviewer traced: + /// the client has already CONNECTED when dispose flags the instance and blocks on the + /// lifecycle gate this connect is holding — so dispose disposes nothing. Without the + /// post-connect re-check the client stays live with an open socket that no later + /// DisposeAsync can ever reach; the broker sees the leak as a connection that never + /// closes. + /// + [Fact] + public async Task DisposeRacingAnInFlightConnect_LeavesNoLiveBrokerConnection() + { + using var broker = new MiniBroker(); + var conn = new MqttConnection(BrokerOpts(broker.Port), driverId: "t", logger: null); + conn.AfterConnectHookForTests = async () => + { + _ = Task.Run(async () => await conn.DisposeAsync()); + (await WaitUntilAsync(() => conn.State == MqttConnectionState.Disposed, TimeSpan.FromSeconds(5))) + .ShouldBeTrue("dispose never flagged the instance — the race was not reproduced"); + }; + + await Should.ThrowAsync(async () => await conn.ConnectAsync(CancellationToken.None)); + + (await WaitUntilAsync(() => broker.LiveConnections == 0, TimeSpan.FromSeconds(15))) + .ShouldBeTrue($"a live broker connection leaked ({broker.LiveConnections} still open)"); + conn.IsConnected.ShouldBeFalse(); + } + // --------------------------------------------------------------------------------- // helpers // --------------------------------------------------------------------------------- + private static MqttDriverOptions LoopbackOpts() + => new() { Host = "127.0.0.1", Port = 1, UseTls = false, ConnectTimeoutSeconds = 1 }; + + /// Options aimed at : plaintext v3.1.1, keep-alive parked out of the way. + private static MqttDriverOptions BrokerOpts(int port) + => new() + { + Host = "127.0.0.1", + Port = port, + UseTls = false, + ProtocolVersion = MqttProtocolVersion.V311, + CleanSession = true, + KeepAliveSeconds = 300, + ConnectTimeoutSeconds = 5, + ReconnectMinBackoffSeconds = 1, + ReconnectMaxBackoffSeconds = 2, + }; + + private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) + { + var deadline = Stopwatch.StartNew(); + while (deadline.Elapsed < timeout) + { + if (condition()) + { + return true; + } + + await Task.Delay(25); + } + + return condition(); + } + private static MqttClientTlsOptions BuildTls(MqttDriverOptions opts) => MqttConnection.BuildClientOptions(opts, clientIdSuffix: null) .ChannelOptions.ShouldBeOfType().TlsOptions; @@ -475,6 +831,7 @@ public sealed class MqttConnectionTests private readonly List _accepted = []; private readonly CancellationTokenSource _cts = new(); private readonly TcpListener _listener; + private int _acceptedCount; public BlackholeBroker() { @@ -486,6 +843,9 @@ public sealed class MqttConnectionTests public int Port { get; } + /// Total TCP connections accepted — how a caller detects a background retry storm. + public int AcceptedCount => Volatile.Read(ref _acceptedCount); + public void Dispose() { _cts.Cancel(); @@ -510,6 +870,7 @@ public sealed class MqttConnectionTests while (!_cts.IsCancellationRequested) { var client = await _listener.AcceptTcpClientAsync(_cts.Token); + Interlocked.Increment(ref _acceptedCount); lock (_accepted) { _accepted.Add(client); @@ -522,4 +883,156 @@ public sealed class MqttConnectionTests } } } + + /// + /// The smallest broker that can complete an MQTT 3.1.1 handshake: accept TCP, answer CONNECT + /// with a success CONNACK, answer PINGREQ with PINGRESP, and nothing else. That is enough for + /// MQTTnet to report a real connection — which is what makes a real drop + /// () drive a real DisconnectedAsync and a real reconnect, rather + /// than a test seam standing in for one. + /// + private sealed class MiniBroker : IDisposable + { + private static readonly byte[] ConnAckAccepted = [0x20, 0x02, 0x00, 0x00]; + private static readonly byte[] PingResp = [0xD0, 0x00]; + + private readonly CancellationTokenSource _cts = new(); + private readonly HashSet _live = []; + private readonly TcpListener _listener; + private int _acceptedCount; + + public MiniBroker() + { + _listener = new TcpListener(IPAddress.Loopback, 0); + _listener.Start(); + Port = ((IPEndPoint)_listener.LocalEndpoint).Port; + _ = Task.Run(AcceptLoopAsync); + } + + public int Port { get; } + + /// When set, TCP is still accepted but CONNECT is never answered (frozen peer). + public bool Blackhole { get; set; } + + /// Total TCP connections accepted since construction — counts reconnect attempts. + public int AcceptedCount => Volatile.Read(ref _acceptedCount); + + /// + /// Sockets the broker still has open. Drops to zero only once the peer actually closes, + /// so a leaked-but-live client keeps this above zero. + /// + public int LiveConnections + { + get + { + lock (_live) + { + return _live.Count; + } + } + } + + /// Kills every established session — the "broker went away" event. + public void DropAll() + { + lock (_live) + { + foreach (var client in _live) + { + try + { + client.Close(); + } + catch (Exception) + { + // Already gone. + } + } + + _live.Clear(); + } + } + + public void Dispose() + { + _cts.Cancel(); + _listener.Stop(); + DropAll(); + _cts.Dispose(); + } + + private async Task AcceptLoopAsync() + { + try + { + while (!_cts.IsCancellationRequested) + { + var client = await _listener.AcceptTcpClientAsync(_cts.Token); + Interlocked.Increment(ref _acceptedCount); + lock (_live) + { + _live.Add(client); + } + + _ = Task.Run(() => ServeAsync(client)); + } + } + catch (Exception) + { + // Listener stopped / token cancelled — the fixture is going away. + } + } + + private async Task ServeAsync(TcpClient client) + { + var buffer = new byte[1024]; + try + { + var stream = client.GetStream(); + while (!_cts.IsCancellationRequested) + { + var read = await stream.ReadAsync(buffer, _cts.Token); + if (read == 0) + { + break; // peer closed — this is how a disposed client stops counting as live + } + + if (Blackhole) + { + continue; + } + + var packetType = buffer[0] & 0xF0; + if (packetType == 0x10) + { + await stream.WriteAsync(ConnAckAccepted, _cts.Token); + } + else if (packetType == 0xC0) + { + await stream.WriteAsync(PingResp, _cts.Token); + } + } + } + catch (Exception) + { + // Socket torn down by either side. + } + finally + { + lock (_live) + { + _live.Remove(client); + } + + try + { + client.Dispose(); + } + catch (Exception) + { + // Already gone. + } + } + } + } } From a6370a26f8c182c8aeda0d23818c3e51307912ae Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 15:32:41 -0400 Subject: [PATCH 10/43] fix(mqtt): make connect idempotent, bound the reconnect callback, stop State lying Closes the connect-vs-connect defect: MQTTnet throws (and raises no DisconnectedAsync) on a connect-while-connected, so a caller's ConnectAsync and the reconnect supervisor corrupted each other in both directions. Both paths now check first; when the supervisor finds the session already restored it stands down WITHOUT firing Reconnected. Reconnected becomes Func, fed from the lifetime token and capped at ConnectTimeoutSeconds, so a hung re-subscribe can no longer park the supervisor. Connected is published only after the re-subscribe succeeds; a supervisor that dies now reports Faulted instead of Reconnecting forever. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../MqttConnection.cs | 314 +++++++++++++++--- .../MqttConnectionTests.cs | 287 +++++++++++++++- 2 files changed, 545 insertions(+), 56 deletions(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs index d01d2444..02f81e7d 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs @@ -22,8 +22,10 @@ public enum MqttConnectionState Reconnecting = 2, /// - /// Unrecoverable configuration: retrying cannot help, so the supervisor stops. Only - /// failures raised while assembling the client options (before any I/O) land here. + /// Unrecoverable without a driver restart: retrying cannot help, so the supervisor has + /// stopped. Two causes — unusable configuration (a failure raised while assembling the client + /// options, before any I/O), or the supervisor itself dying on an unexpected exception. A + /// broker that is merely unreachable never lands here. /// Faulted = 3, @@ -69,7 +71,64 @@ public enum MqttConnectionState /// persistent-session reconnects, where re-subscribing is merely redundant. Re-subscribing /// an already-subscribed topic is harmless; missing one is silent death. If the callback /// fails, the freshly-established session is torn down and retried rather than left -/// connected-but-deaf. +/// connected-but-deaf. It is invoked under a cancellation token and a +/// ceiling: a subscriber that hangs on +/// a broker which accepts SUBSCRIBE and never SUBACKs — the frozen-peer shape again — must +/// not be able to park the supervisor or outlive . +/// +/// +/// Connect is idempotent. MQTTnet throws (and raises no DisconnectedAsync) if +/// asked to connect a client that is already connected, so both connect paths check first and +/// return a no-op. The two paths genuinely race: the supervisor is told to expect the driver +/// host to re-run InitializeAsync, so a caller can restore the session while the +/// supervisor sits in backoff, and the supervisor can restore it a moment before a caller +/// asks. Without the check, the first case leaves the supervisor failing forever against a +/// perfectly healthy connection (inflating attempt, so the next genuine outage +/// waits instead of min) and the +/// second throws an undocumented out of +/// on a working session. When the supervisor finds the session +/// already restored it stops retrying without firing — the +/// caller that reconnected owns its own subscribe, per the +/// contract. +/// +/// +/// State transitions. is terminal; nothing +/// moves off it. +/// +/// +/// +/// +/// set by on success (or on finding the session +/// already up), and by the supervisor only after +/// has completed — so on the reconnect path +/// Connected means "connected and re-subscribed". +/// +/// +/// +/// +/// +/// set by the DisconnectedAsync callback when a live session drops, and +/// held by the supervisor for the whole retry + re-subscribe sequence. +/// +/// +/// +/// +/// +/// set when the client options cannot be assembled, or when the supervisor exits +/// on an unexpected exception. Both mean no further recovery without a restart. +/// +/// +/// +/// +/// set first thing in . +/// +/// +/// and can legitimately disagree in both +/// directions and are not interchangeable: Reconnecting with +/// IsConnected == true is the window where the socket is up but the re-subscribe has +/// not finished, and Connected with IsConnected == false is the instant between +/// the socket dropping and MQTTnet raising its callback. is the health +/// surface; is the transport fact. /// /// /// Credentials never leak. Nothing here logs or formats @@ -99,7 +158,15 @@ public sealed class MqttConnection : IAsyncDisposable /// private readonly SemaphoreSlim _lifecycleGate = new(1, 1); - /// Cancelled by ; aborts the supervisor and any in-flight connect. + /// + /// Cancelled by ; aborts the supervisor, any in-flight connect and + /// any in-flight callback. Never disposed — deliberately, like the + /// two semaphores: a connect racing dispose reads + /// after its own disposed check, and disposing the source would turn that benign loser into an + /// naming the wrong type. It holds no timer and no + /// surviving registrations (every linked source here is using-scoped), so leaving it + /// undisposed costs nothing. + /// private readonly CancellationTokenSource _lifetimeCts = new(); private readonly ILogger? _logger; @@ -135,12 +202,20 @@ public sealed class MqttConnection : IAsyncDisposable /// /// Raised after every successful reconnect (never after the initial - /// , which the caller already sequences its own subscribe behind). + /// , which the caller already sequences its own subscribe behind, + /// and never when the supervisor merely finds the session already restored by such a caller). /// Subscribers must re-establish their MQTT subscriptions here — see the type remarks for /// why skipping it is silent death. Every subscriber is invoked even if an earlier one /// throws; a throwing subscriber causes the session to be torn down and retried. /// - public event Func? Reconnected; + /// + /// The supplied token is cancelled by , and the whole fan-out is + /// additionally capped at — a subscriber + /// that ignores its token cannot park the supervisor or make it outlive teardown. Subscribers + /// should flow the token into their own network calls rather than relying on that ceiling, + /// which abandons rather than stops the offending work. + /// + public event Func? Reconnected; /// Whether the underlying client currently holds an established MQTT session. public bool IsConnected => _client?.IsConnected ?? false; @@ -285,11 +360,20 @@ public sealed class MqttConnection : IAsyncDisposable /// supervisor takes over keeping the session alive. /// /// - /// May be retried on the same instance after a failed attempt — the underlying - /// is created once and reused across attempts. Safe to call - /// concurrently with : the two are serialised, and a connect that - /// loses the race disposes whatever it built and throws - /// . + /// + /// May be retried on the same instance after a failed attempt — the underlying + /// is created once and reused across attempts. Safe to call + /// concurrently with : the two are serialised, and a connect + /// that loses the race disposes whatever it built and throws + /// . + /// + /// + /// Idempotent. Calling it on a session that is already established — including one + /// the reconnect supervisor restored a moment earlier — is a no-op that returns normally, + /// not the MQTTnet would raise for a + /// connect-while-connected. The caller still owns re-establishing its own subscriptions + /// after this returns; is not fired for this path. + /// /// /// Caller cancellation; linked with the connect deadline. /// The connect deadline elapsed. @@ -297,21 +381,22 @@ public sealed class MqttConnection : IAsyncDisposable /// /// The connection was disposed, either before the attempt started or while it was in flight. /// - public Task ConnectAsync(CancellationToken cancellationToken) + public async Task ConnectAsync(CancellationToken cancellationToken) { ObjectDisposedException.ThrowIf(Disposed, this); - return ConnectCoreAsync(startSupervisor: true, cancellationToken); + await ConnectCoreAsync(supervisorAttempt: false, cancellationToken).ConfigureAwait(false); } /// /// Invokes every subscriber. Walks the invocation list explicitly: - /// awaiting a multicast directly yields only the last - /// subscriber's task and abandons the earlier ones, so a single throwing subscriber would - /// silently skip every subscriber behind it — and a skipped re-subscribe is a topic that goes - /// dark. Failures are collected and rethrown after every subscriber has had its turn. + /// awaiting a multicast delegate directly yields only the last subscriber's task and + /// abandons the earlier ones, so a single throwing subscriber would silently skip every + /// subscriber behind it — and a skipped re-subscribe is a topic that goes dark. Failures are + /// collected and rethrown after every subscriber has had its turn. /// - internal async Task FireReconnectedAsync() + /// Passed to every subscriber; cancelled by . + internal async Task FireReconnectedAsync(CancellationToken cancellationToken) { var subscribers = Reconnected; if (subscribers is null) @@ -320,11 +405,11 @@ public sealed class MqttConnection : IAsyncDisposable } List? failures = null; - foreach (var subscriber in subscribers.GetInvocationList().Cast>()) + foreach (var subscriber in subscribers.GetInvocationList().Cast>()) { try { - await subscriber().ConfigureAwait(false); + await subscriber(cancellationToken).ConfigureAwait(false); } catch (Exception ex) { @@ -340,7 +425,23 @@ public sealed class MqttConnection : IAsyncDisposable } } - private async Task ConnectCoreAsync(bool startSupervisor, CancellationToken cancellationToken) + /// + /// The single gated connect both entry points funnel through. + /// + /// + /// true when the reconnect supervisor is calling. It suppresses publishing + /// on success, because on the reconnect path + /// "connected" is only true once has re-subscribed — publishing it + /// here would advertise a healthy connection during the window in which it has no + /// subscriptions at all. It also suppresses starting the supervisor (it is the supervisor). + /// + /// Caller cancellation; linked with the connect deadline. + /// + /// true if this call established the session; false if it found one already + /// established and did nothing. The supervisor uses false to stop retrying without + /// firing — whoever established that session owns its subscribe. + /// + private async Task ConnectCoreAsync(bool supervisorAttempt, CancellationToken cancellationToken) { // The options are rebuilt per attempt so a rotated CA file is picked up on the next connect // rather than being pinned for the life of the process. This is also the only step that can @@ -397,6 +498,23 @@ public sealed class MqttConnection : IAsyncDisposable _client = client; } + // Idempotence, and the whole point of the gate: MQTTnet throws + // "not allowed to connect with a server after the connection is established" for a + // connect-while-connected AND raises no DisconnectedAsync for it, so neither the retry + // loop nor Classify would ever recover. Both entry points race for real — see the type + // remarks — so whichever arrives second must find the session and leave it alone. + if (client.IsConnected) + { + SetState(MqttConnectionState.Connected); + StartSupervisorIfCallerConnect(supervisorAttempt); + _logger?.LogDebug( + "MQTT driver '{DriverId}': connect to {Host}:{Port} skipped — the session is already established.", + _driverId, + _options.Host, + _options.Port); + return false; + } + await client.ConnectAsync(clientOptions, linked.Token).ConfigureAwait(false); if (AfterConnectHookForTests is { } hook) @@ -417,12 +535,15 @@ public sealed class MqttConnection : IAsyncDisposable + $"{_options.Host}:{_options.Port} was in flight; the established session was torn down."); } - SetState(MqttConnectionState.Connected); - - if (startSupervisor) + // On the supervisor path the session is up but has no subscriptions yet, so State stays + // Reconnecting until FireReconnectedAsync succeeds — see the state table in the remarks. + if (!supervisorAttempt) { - _reconnectWorker ??= Task.Run(() => ReconnectSupervisorAsync(_lifetimeCts.Token)); + SetState(MqttConnectionState.Connected); } + + StartSupervisorIfCallerConnect(supervisorAttempt); + return true; } catch (ObjectDisposedException) { @@ -438,6 +559,18 @@ public sealed class MqttConnection : IAsyncDisposable } } + /// + /// Starts the supervisor on the first successful caller connect. Always called while + /// holding the lifecycle gate, so the ??= needs no further synchronisation. + /// + private void StartSupervisorIfCallerConnect(bool supervisorAttempt) + { + if (!supervisorAttempt) + { + _reconnectWorker ??= Task.Run(() => ReconnectSupervisorAsync(_lifetimeCts.Token)); + } + } + /// /// Maps a connect failure onto this type's documented contract. MQTTnet wraps a cancelled /// connect in MqttConnectingFailedException rather than letting the @@ -537,6 +670,14 @@ public sealed class MqttConnection : IAsyncDisposable for (var attempt = 0; !cancellationToken.IsCancellationRequested && !Disposed; attempt++) { + // A caller's ConnectAsync may have restored the session while we slept. Bail + // before burning the backoff, not just before burning an attempt. + if (IsConnected) + { + AdoptSessionRestoredByCaller(); + break; + } + await Task .Delay( NextBackoff( @@ -546,11 +687,13 @@ public sealed class MqttConnection : IAsyncDisposable cancellationToken) .ConfigureAwait(false); + bool established; try { - await ConnectCoreAsync(startSupervisor: false, cancellationToken).ConfigureAwait(false); + established = await ConnectCoreAsync(supervisorAttempt: true, cancellationToken) + .ConfigureAwait(false); } - catch (ObjectDisposedException) + catch (ObjectDisposedException) when (Disposed) { return; } @@ -575,25 +718,24 @@ public sealed class MqttConnection : IAsyncDisposable continue; } - try + if (!established) { - // ALWAYS, on every reconnect — including persistent sessions, where this is - // merely redundant. Subscriptions do not survive a clean session, and a - // reconnect that skips this leaves a healthy-looking, permanently deaf client. - await FireReconnectedAsync().ConfigureAwait(false); + // Someone else restored the session between the backoff and the connect. + // Firing Reconnected here would re-subscribe on top of a caller that is about + // to do exactly that itself, and — worse — leaving the loop running would + // keep failing against a healthy connection forever. + AdoptSessionRestoredByCaller(); + break; } - catch (Exception ex) + + if (!await TryReSubscribeAsync(cancellationToken).ConfigureAwait(false)) { - _logger?.LogError( - ex, - "MQTT driver '{DriverId}': re-subscribe after reconnect failed; tearing the session down " - + "and retrying rather than serving a connection that receives nothing.", - _driverId); - SetState(MqttConnectionState.Reconnecting); - await ForceDisconnectAsync().ConfigureAwait(false); continue; } + // Only now is "Connected" true in the sense this type sells it: connected AND + // subscribed. + SetState(MqttConnectionState.Connected); _logger?.LogInformation( "MQTT driver '{DriverId}': reconnected to {Host}:{Port} after {Attempts} attempt(s); " + "subscriptions re-established.", @@ -612,7 +754,9 @@ public sealed class MqttConnection : IAsyncDisposable catch (Exception ex) { // Reaching here means the connection is permanently dark, which is exactly the failure - // this supervisor exists to prevent — it must never be swallowed quietly. + // this supervisor exists to prevent — it must never be swallowed quietly, and State must + // not keep reporting Reconnecting, which every consumer reads as "recovering". + SetState(MqttConnectionState.Faulted); _logger?.LogError( ex, "MQTT driver '{DriverId}': reconnect supervisor stopped unexpectedly; the broker session will not " @@ -621,6 +765,71 @@ public sealed class MqttConnection : IAsyncDisposable } } + /// + /// The session came back without this supervisor's help — a caller's + /// won the race while we were in backoff. Publish the truth and stand down; deliberately does + /// not fire , because that caller owns its own re-subscribe. + /// + private void AdoptSessionRestoredByCaller() + { + SetState(MqttConnectionState.Connected); + _logger?.LogDebug( + "MQTT driver '{DriverId}': reconnect stood down — the session to {Host}:{Port} was restored elsewhere.", + _driverId, + _options.Host, + _options.Port); + } + + /// + /// Fires — ALWAYS, on every reconnect, including persistent + /// sessions where it is merely redundant, because subscriptions do not survive a clean + /// session and a reconnect that skips this leaves a healthy-looking, permanently deaf client. + /// + /// + /// false if the re-subscribe failed or overran its deadline, having torn the session + /// down so the next attempt starts from a clean CONNECT — serving a connection that receives + /// nothing is the one outcome this type must never produce. + /// + private async Task TryReSubscribeAsync(CancellationToken cancellationToken) + { + // Bounded like every other wait here. A subscriber that flows the token cancels promptly; one + // that ignores it is abandoned at the ceiling rather than being allowed to park the + // supervisor past DisposeAsync. + var fanOut = FireReconnectedAsync(cancellationToken); + + // The ceiling abandons the task rather than stopping it, so its eventual failure must still + // be observed or it resurfaces as an unobserved TaskException on the finalizer thread. + _ = fanOut.ContinueWith( + static abandoned => _ = abandoned.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + + try + { + await fanOut + .WaitAsync(TimeSpan.FromSeconds(_options.ConnectTimeoutSeconds), cancellationToken) + .ConfigureAwait(false); + return true; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; // Teardown — let the supervisor's own cancellation handling end the loop. + } + catch (Exception ex) + { + _logger?.LogError( + ex, + "MQTT driver '{DriverId}': re-subscribe after reconnect failed or overran {TimeoutSeconds}s; tearing " + + "the session down and retrying rather than serving a connection that receives nothing.", + _driverId, + _options.ConnectTimeoutSeconds); + SetState(MqttConnectionState.Reconnecting); + await ForceDisconnectAsync().ConfigureAwait(false); + return false; + } + } + /// /// Drops a session that is connected but unusable, so the next supervisor pass starts from a /// clean CONNECT. Best-effort and bounded — failure here just means the next attempt sees a @@ -666,15 +875,28 @@ public sealed class MqttConnection : IAsyncDisposable } } - /// is terminal — nothing may move off it. + /// + /// is terminal — nothing may move off it. A + /// check-then-write would let a thread preempted between the two steps resurrect a disposed + /// connection's state, so the transition is a compare-and-swap: the terminal check and the + /// write are the same atomic operation. + /// private void SetState(MqttConnectionState state) { - if (Volatile.Read(ref _state) == (int)MqttConnectionState.Disposed) + var desired = (int)state; + while (true) { - return; - } + var current = Volatile.Read(ref _state); + if (current == (int)MqttConnectionState.Disposed || current == desired) + { + return; + } - Volatile.Write(ref _state, (int)state); + if (Interlocked.CompareExchange(ref _state, desired, current) == current) + { + return; + } + } } /// diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttConnectionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttConnectionTests.cs index b4b5a3b9..a0986238 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttConnectionTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttConnectionTests.cs @@ -4,6 +4,7 @@ using System.Net.Security; using System.Net.Sockets; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Logging; using MQTTnet; using Shouldly; using Xunit; @@ -447,10 +448,10 @@ public sealed class MqttConnectionTests await using var conn = new MqttConnection(LoopbackOpts(), driverId: "t", logger: null); var first = 0; var second = 0; - conn.Reconnected += () => { Interlocked.Increment(ref first); return Task.CompletedTask; }; - conn.Reconnected += () => { Interlocked.Increment(ref second); return Task.CompletedTask; }; + conn.Reconnected += _ => { Interlocked.Increment(ref first); return Task.CompletedTask; }; + conn.Reconnected += _ => { Interlocked.Increment(ref second); return Task.CompletedTask; }; - await conn.FireReconnectedAsync(); + await conn.FireReconnectedAsync(TestContext.Current.CancellationToken); first.ShouldBe(1); second.ShouldBe(1); @@ -467,10 +468,10 @@ public sealed class MqttConnectionTests { await using var conn = new MqttConnection(LoopbackOpts(), driverId: "t", logger: null); var behindTheThrower = 0; - conn.Reconnected += () => throw new InvalidOperationException("SUBACK refused"); - conn.Reconnected += () => { Interlocked.Increment(ref behindTheThrower); return Task.CompletedTask; }; + conn.Reconnected += _ => throw new InvalidOperationException("SUBACK refused"); + conn.Reconnected += _ => { Interlocked.Increment(ref behindTheThrower); return Task.CompletedTask; }; - await Should.ThrowAsync(async () => await conn.FireReconnectedAsync()); + await Should.ThrowAsync(async () => await conn.FireReconnectedAsync(TestContext.Current.CancellationToken)); behindTheThrower.ShouldBe(1); } @@ -480,7 +481,7 @@ public sealed class MqttConnectionTests { await using var conn = new MqttConnection(LoopbackOpts(), driverId: "t", logger: null); - await Should.NotThrowAsync(async () => await conn.FireReconnectedAsync()); + await Should.NotThrowAsync(async () => await conn.FireReconnectedAsync(TestContext.Current.CancellationToken)); } // --------------------------------------------------------------------------------- @@ -594,7 +595,7 @@ public sealed class MqttConnectionTests using var broker = new MiniBroker(); await using var conn = new MqttConnection(BrokerOpts(broker.Port), driverId: "t", logger: null); var resubscribes = 0; - conn.Reconnected += () => { Interlocked.Increment(ref resubscribes); return Task.CompletedTask; }; + conn.Reconnected += _ => { Interlocked.Increment(ref resubscribes); return Task.CompletedTask; }; await conn.ConnectAsync(CancellationToken.None); @@ -684,20 +685,248 @@ public sealed class MqttConnectionTests { using var broker = new MiniBroker(); var conn = new MqttConnection(BrokerOpts(broker.Port), driverId: "t", logger: null); + + // No assertions inside the hook: it runs inside the production try, so a Shouldly throw would + // be laundered through Classify and resurface as a confusing TimeoutException. Record, assert + // outside. + var raceReproduced = false; conn.AfterConnectHookForTests = async () => { _ = Task.Run(async () => await conn.DisposeAsync()); - (await WaitUntilAsync(() => conn.State == MqttConnectionState.Disposed, TimeSpan.FromSeconds(5))) - .ShouldBeTrue("dispose never flagged the instance — the race was not reproduced"); + raceReproduced = await WaitUntilAsync( + () => conn.State == MqttConnectionState.Disposed, + TimeSpan.FromSeconds(5)); }; await Should.ThrowAsync(async () => await conn.ConnectAsync(CancellationToken.None)); + raceReproduced.ShouldBeTrue("dispose never flagged the instance — the race was not reproduced"); (await WaitUntilAsync(() => broker.LiveConnections == 0, TimeSpan.FromSeconds(15))) .ShouldBeTrue($"a live broker connection leaked ({broker.LiveConnections} still open)"); conn.IsConnected.ShouldBeFalse(); } + // --------------------------------------------------------------------------------- + // C1 — connect-vs-connect: the supervisor and ConnectAsync must not corrupt each other + // --------------------------------------------------------------------------------- + + /// + /// Direction (b), the simplest form: MQTTnet throws + /// InvalidOperationException: It is not allowed to connect with a server after the + /// connection is established for a connect-while-connected. That is outside this type's + /// documented contract, so a caller re-running InitializeAsync against a healthy + /// session would look like a hard driver failure. + /// + [Fact] + public async Task ConnectAsync_OnAnAlreadyEstablishedSession_IsANoOp_NotAnInvalidOperation() + { + using var broker = new MiniBroker(); + await using var conn = new MqttConnection(BrokerOpts(broker.Port), driverId: "t", logger: null); + + await conn.ConnectAsync(CancellationToken.None); + await Should.NotThrowAsync(async () => await conn.ConnectAsync(CancellationToken.None)); + + conn.IsConnected.ShouldBeTrue(); + conn.State.ShouldBe(MqttConnectionState.Connected); + broker.AcceptedCount.ShouldBe(1, "the second connect opened a second socket instead of no-opping"); + } + + /// + /// Direction (b) through the supervisor: the supervisor restores the session, then the driver + /// host re-runs InitializeAsync — exactly the sequence this design relies on. + /// + [Fact] + public async Task ConnectAsync_AfterTheSupervisorAlreadyReconnected_IsANoOp() + { + using var broker = new MiniBroker(); + await using var conn = new MqttConnection(BrokerOpts(broker.Port), driverId: "t", logger: null); + var resubscribes = 0; + conn.Reconnected += _ => { Interlocked.Increment(ref resubscribes); return Task.CompletedTask; }; + + await conn.ConnectAsync(CancellationToken.None); + broker.DropAll(); + (await WaitUntilAsync( + () => Volatile.Read(ref resubscribes) >= 1 && conn.State == MqttConnectionState.Connected, + TimeSpan.FromSeconds(30))) + .ShouldBeTrue(); + + await Should.NotThrowAsync(async () => await conn.ConnectAsync(CancellationToken.None)); + + conn.IsConnected.ShouldBeTrue(); + conn.State.ShouldBe(MqttConnectionState.Connected); + } + + /// + /// Direction (a): a caller reconnects while the supervisor is asleep in backoff. The + /// supervisor must find the restored session and stand down — silently, without firing + /// Reconnected (the caller owns its own re-subscribe). Left unfixed it fails forever + /// against a perfectly healthy connection, at Debug level, inflating attempt so the + /// NEXT genuine outage waits max backoff instead of min. + /// + [Fact] + public async Task CallerReconnectsWhileTheSupervisorIsInBackoff_SupervisorStandsDownSilently() + { + using var broker = new MiniBroker(); + var log = new CapturingLogger(); + var opts = BrokerOpts(broker.Port) with { ReconnectMinBackoffSeconds = 3, ReconnectMaxBackoffSeconds = 3 }; + await using var conn = new MqttConnection(opts, driverId: "t", logger: log); + var resubscribes = 0; + conn.Reconnected += _ => { Interlocked.Increment(ref resubscribes); return Task.CompletedTask; }; + + await conn.ConnectAsync(CancellationToken.None); + broker.DropAll(); + (await WaitUntilAsync(() => conn.State == MqttConnectionState.Reconnecting, TimeSpan.FromSeconds(10))) + .ShouldBeTrue(); + + // The caller wins the race while the supervisor sleeps off its 3 s backoff. + await conn.ConnectAsync(CancellationToken.None); + conn.IsConnected.ShouldBeTrue(); + var failuresBefore = log.CountContaining("reconnect attempt"); + + // Three more supervisor wake points would land in this window if it were still looping. + await Task.Delay(TimeSpan.FromSeconds(9), TestContext.Current.CancellationToken); + + log.CountContaining("reconnect attempt") + .ShouldBe(failuresBefore, "the supervisor kept failing against a healthy connection"); + conn.State.ShouldBe(MqttConnectionState.Connected); + conn.IsConnected.ShouldBeTrue(); + Volatile.Read(ref resubscribes).ShouldBe(0, "the caller owns its own re-subscribe; Reconnected must not fire"); + } + + /// + /// The operational consequence of direction (a), measured rather than argued: after a caller + /// has won a reconnect race, the supervisor must be parked at its outer wait with its attempt + /// counter reset, so the NEXT genuine outage recovers at MIN backoff. + /// + /// + /// The timings are load-bearing, not arbitrary. A supervisor still flailing against the healthy + /// connection would have burned attempts 0 and 1 (at t≈2 s and t≈6 s) and be asleep in an 8 s + /// backoff running to t≈14 s, so the outage induced at t≈7 s cannot be answered before then. + /// A stood-down supervisor answers it one min-backoff (2 s) later. The 5 s window separates the + /// two by a clear margin; widening the burn only widens the separation. + /// + [Fact] + public async Task AfterACallerWinsTheRace_TheNextGenuineOutageStillRecoversAtMinBackoff() + { + using var broker = new MiniBroker(); + var opts = BrokerOpts(broker.Port) with { ReconnectMinBackoffSeconds = 2, ReconnectMaxBackoffSeconds = 30 }; + await using var conn = new MqttConnection(opts, driverId: "t", logger: null); + var resubscribes = 0; + conn.Reconnected += _ => { Interlocked.Increment(ref resubscribes); return Task.CompletedTask; }; + + await conn.ConnectAsync(CancellationToken.None); + broker.DropAll(); + (await WaitUntilAsync(() => conn.State == MqttConnectionState.Reconnecting, TimeSpan.FromSeconds(10))) + .ShouldBeTrue(); + await conn.ConnectAsync(CancellationToken.None); // caller wins the race + await Task.Delay(TimeSpan.FromSeconds(7), TestContext.Current.CancellationToken); // a broken loop climbs here + + broker.DropAll(); // the NEXT genuine outage + var recovered = await WaitUntilAsync(() => Volatile.Read(ref resubscribes) >= 1, TimeSpan.FromSeconds(5)); + + recovered.ShouldBeTrue("recovery took far longer than min backoff — the attempt counter never reset"); + conn.State.ShouldBe(MqttConnectionState.Connected); + } + + // --------------------------------------------------------------------------------- + // I2 / M3 — the Reconnected callback is bounded, and Connected means "connected AND subscribed" + // --------------------------------------------------------------------------------- + + [Fact] + public async Task FireReconnected_PassesTheSuppliedTokenToEverySubscriber() + { + await using var conn = new MqttConnection(LoopbackOpts(), driverId: "t", logger: null); + using var cts = new CancellationTokenSource(); + var seen = new List(); + conn.Reconnected += ct => { seen.Add(ct); return Task.CompletedTask; }; + conn.Reconnected += ct => { seen.Add(ct); return Task.CompletedTask; }; + + await conn.FireReconnectedAsync(cts.Token); + + seen.Count.ShouldBe(2); + seen.ShouldAllBe(t => t == cts.Token); + } + + /// + /// A re-subscribe that hangs — broker accepts SUBSCRIBE and never SUBACKs, the frozen-peer + /// shape again — must not park the supervisor. This subscriber deliberately ignores its + /// token, so only the fan-out's own ceiling can end the wait. + /// + /// + /// Asserted at the transport, not at dispose timing: with the ceiling, each hung re-subscribe + /// is abandoned, the session torn down, and another CONNECT reaches the broker; without it, + /// the supervisor is parked forever on the first one and the broker never sees another. A + /// dispose-elapsed assertion cannot tell those apart — DisposeAsync's own bounded join + /// returns promptly either way, leaving the supervisor alive behind it. + /// + [Fact] + public async Task ReSubscribeThatHangsIgnoringItsToken_DoesNotParkTheSupervisor() + { + using var broker = new MiniBroker(); + var opts = BrokerOpts(broker.Port) with + { + ConnectTimeoutSeconds = 2, ReconnectMinBackoffSeconds = 1, ReconnectMaxBackoffSeconds = 2, + }; + var conn = new MqttConnection(opts, driverId: "t", logger: null); + var entered = new TaskCompletionSource(); + conn.Reconnected += async _ => + { + entered.TrySetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan); // no token — only the ceiling bounds this + }; + + await conn.ConnectAsync(CancellationToken.None); + broker.DropAll(); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken); + + // The first reconnect is accept #2; a supervisor that recovered from the hang makes at least + // one more. A parked one never does. + (await WaitUntilAsync(() => broker.AcceptedCount >= 3, TimeSpan.FromSeconds(25))) + .ShouldBeTrue($"the supervisor is parked behind a hung re-subscribe ({broker.AcceptedCount} connects seen)"); + + var sw = Stopwatch.StartNew(); + await conn.DisposeAsync(); + sw.Stop(); + + sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(20), "dispose was parked behind a hung re-subscribe"); + conn.State.ShouldBe(MqttConnectionState.Disposed); + var acceptedAtDispose = broker.AcceptedCount; + await Task.Delay(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + broker.AcceptedCount.ShouldBe(acceptedAtDispose, "the supervisor outlived DisposeAsync"); + } + + /// + /// M3: on the reconnect path the socket comes up before the subscriptions do. Publishing + /// Connected in that window advertises exactly the healthy-looking-but-deaf condition + /// this type exists to prevent. It is also the documented case where State and + /// IsConnected legitimately disagree. + /// + [Fact] + public async Task Reconnect_DoesNotPublishConnectedUntilTheReSubscribeHasCompleted() + { + using var broker = new MiniBroker(); + await using var conn = new MqttConnection(BrokerOpts(broker.Port), driverId: "t", logger: null); + var inSubscriber = new TaskCompletionSource(); + var release = new TaskCompletionSource(); + conn.Reconnected += async _ => + { + inSubscriber.TrySetResult(); + await release.Task; + }; + + await conn.ConnectAsync(CancellationToken.None); + broker.DropAll(); + await inSubscriber.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken); + + conn.IsConnected.ShouldBeTrue("the socket is up"); + conn.State.ShouldBe(MqttConnectionState.Reconnecting, "…but it has no subscriptions yet"); + + release.SetResult(); + + (await WaitUntilAsync(() => conn.State == MqttConnectionState.Connected, TimeSpan.FromSeconds(10))) + .ShouldBeTrue(); + } + // --------------------------------------------------------------------------------- // helpers // --------------------------------------------------------------------------------- @@ -720,6 +949,44 @@ public sealed class MqttConnectionTests ReconnectMaxBackoffSeconds = 2, }; + /// + /// Captures formatted log messages. The supervisor's failed-attempt path is Debug-only and has + /// no other externally visible effect — MQTTnet refuses a connect-while-connected before + /// opening a socket, so the broker never sees it — which is precisely why the C1 defect was + /// silent. The log is the observable. + /// + private sealed class CapturingLogger : ILogger + { + private readonly List _messages = []; + + public int CountContaining(string fragment) + { + lock (_messages) + { + return _messages.Count(m => m.Contains(fragment, StringComparison.OrdinalIgnoreCase)); + } + } + + public IDisposable? BeginScope(TState state) + where TState : notnull + => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + lock (_messages) + { + _messages.Add(formatter(state, exception)); + } + } + } + private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) { var deadline = Stopwatch.StartNew(); From 7980b346921752a87dcacab5e9c0b8aa92aa6565 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 15:45:32 -0400 Subject: [PATCH 11/43] feat(mqtt): bespoke passive #-observation browser (plain) MQTT has no discovery protocol, so the AdminUI address picker learns the topic space the only way there is: subscribe to the wildcard and accumulate what arrives. MqttBrowseSession serves that observation as a segment tree (split on '/'); MqttDriverBrowser opens it with a distinct "{clientId}-browse-{guid8}" identity under a 5-30 s clamped budget. Browsing publishes NOTHING - the load-bearing safety property, since the picker runs against a live plant broker. Every outbound message must route through the single PublishAsync seam that counts into PublishCountForTest; P2's operator- triggered Sparkplug rebirth is the one intended exception. Sparkplug mode fails fast rather than serving a raw spBv1.0 topic tree that looks like a metric tree. Deviation from the plan's ref list: the project also references .Driver so the browse CONNECT reuses MqttConnection.BuildClientOptions (TLS posture, CA-pin chain validator, credentials). A second copy would be a security divergence. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- ZB.MOM.WW.OtOpcUa.slnx | 1 + .../MqttBrowseSession.cs | 365 ++++++++++++++++++ .../MqttDriverBrowser.cs | 172 +++++++++ ....MOM.WW.OtOpcUa.Driver.Mqtt.Browser.csproj | 36 ++ .../MqttBrowseSessionTests.cs | 321 +++++++++++++++ ...ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests.csproj | 1 + 6 files changed, 896 insertions(+) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser.csproj create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowseSessionTests.cs diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index e07dc176..02a8700d 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -44,6 +44,7 @@ + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs new file mode 100644 index 00000000..eca93d61 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs @@ -0,0 +1,365 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Text; +using Microsoft.Extensions.Logging; +using MQTTnet; +using ZB.MOM.WW.OtOpcUa.Commons.Browsing; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser; + +/// +/// A passive MQTT observation window, served through the AdminUI's standard browse-session +/// interface. +/// +/// MQTT has no discovery protocol — a broker cannot be asked "what topics exist". The only way +/// to learn the topic space is to subscribe to a wildcard and watch what arrives. So this +/// session accumulates every topic it observes into a segment tree (split on /) and +/// serves that tree one level at a time. It is therefore inherently incomplete: a topic +/// that never publishes during the window is invisible. That is a property of MQTT, not a +/// defect — the picker always keeps manual entry as the escape hatch. +/// +/// +/// The load-bearing safety property is that browsing publishes nothing. A picker opened +/// by an operator runs against a live production broker; if browse published anything, +/// opening an address picker could inject messages into a running plant. Every outbound +/// message must route through the single seam, which counts its +/// calls into so the guarantee is assertable. In P1 nothing +/// calls it; P2's operator-triggered Sparkplug rebirth (Task 23) is the one intended exception. +/// +/// +/// Observations arrive on MQTTnet's dispatcher thread while the picker calls +/// from the UI, so the tree is built from concurrent collections and +/// every mutation is lock-free. +/// +/// +internal sealed class MqttBrowseSession : IBrowseSession +{ + /// Default cap on recorded nodes; a busy broker's # firehose is unbounded. + internal const int DefaultNodeCap = 50_000; + + /// Maximum characters of a payload retained as the picker's confirm-it's-the-right-topic snippet. + internal const int PayloadSnippetMaxChars = 64; + + /// Sentinel node id of the "results truncated" marker, mirroring CapturedTreeBrowseSession. + internal const string TruncatedNodeId = "__truncated__"; + + /// Bound on the courtesy DISCONNECT issued at dispose — teardown never hangs on a dead broker. + private static readonly TimeSpan DisconnectTimeout = TimeSpan.FromSeconds(5); + + private readonly IMqttClient? _client; + private readonly ILogger? _logger; + private readonly int _nodeCap; + + /// Flat path → node index, so is O(1) rather than a tree walk. + private readonly ConcurrentDictionary _byPath = new(StringComparer.Ordinal); + + /// First-segment nodes, keyed by segment. + private readonly ConcurrentDictionary _roots = new(StringComparer.Ordinal); + + private int _disposed; + private long _lastUsedTicksUtc = DateTime.UtcNow.Ticks; + private int _nodeCount; + private int _publishCount; + private int _truncated; + + /// + /// Test seam: an unconnected session whose tree is fed through + /// . The accumulating tree is the whole of the P1 logic, so it + /// is exercised without a live broker. + /// + /// The ingest shape the session was opened for. + /// Cap on recorded nodes; defaults to . + internal MqttBrowseSession(MqttMode mode, int nodeCap = DefaultNodeCap) + : this(mode, client: null, logger: null, nodeCap) { } + + /// Production constructor: takes ownership of an already-connected client. + /// The ingest shape the session was opened for. + /// The connected MQTT client; disconnected and disposed by . + /// Optional logger; never receives credentials or payload bodies. + /// Cap on recorded nodes; defaults to . + internal MqttBrowseSession(MqttMode mode, IMqttClient? client, ILogger? logger, int nodeCap = DefaultNodeCap) + { + Mode = mode; + _client = client; + _logger = logger; + _nodeCap = nodeCap; + } + + /// + /// Number of messages this session has published. Always 0 in P1 — the assertion that + /// browsing a live plant broker is strictly read-only. + /// + internal int PublishCountForTest => Volatile.Read(ref _publishCount); + + /// True once the node cap stopped recording; surfaced as a marker node from . + internal bool Truncated => Volatile.Read(ref _truncated) != 0; + + private bool Disposed => Volatile.Read(ref _disposed) != 0; + + /// + /// The ingest shape this session was opened for. P1 serves only; + /// the P2 Sparkplug tasks branch the tree shape (Group → EdgeNode → Device → Metric) on it. + /// + internal MqttMode Mode { get; } + + /// + public Guid Token { get; } = Guid.NewGuid(); + + /// + public DateTime LastUsedUtc => new(Interlocked.Read(ref _lastUsedTicksUtc), DateTimeKind.Utc); + + /// + public Task> RootAsync(CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(Disposed, this); + var nodes = Project(_roots); + if (Truncated) + { + nodes.Add(new BrowseNode( + TruncatedNodeId, + "⚠ Observation truncated — narrow the topic prefix or use manual entry", + BrowseNodeKind.Folder, + HasChildrenHint: false)); + } + + Touch(); + return Task.FromResult>(nodes); + } + + /// + /// + /// is the full slash-joined topic path of the node + /// (e.g. factory/line3/oven) — the same value carried on the + /// the picker was handed. An unknown path returns empty rather than throwing: the tree grows + /// while the picker browses it, so a stale id is an ordinary race, not an error. + /// + public Task> ExpandAsync(string nodeId, CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(Disposed, this); + var children = _byPath.TryGetValue(nodeId ?? "", out var node) + ? Project(node.Children) + : []; + Touch(); + return Task.FromResult>(children); + } + + /// + /// + /// A topic has no attribute model — MQTT carries an opaque payload. The single synthetic + /// attribute returned here exists to help the operator confirm they picked the right topic: + /// carries the type inferred from the last + /// observed payload (the authored tag's own dataType remains the authority — inference + /// is the brittle fallback, per design §4), and + /// carries that payload's snippet. The SecurityClass slot is reused deliberately: it is + /// the picker's free-text descriptor line (rendered as + /// {DriverDataType} · {SecurityClass}) and MQTT has no security classes of its own. + /// Returns empty for a synthesised path segment nothing was ever published to. + /// + public Task> AttributesAsync(string nodeId, CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(Disposed, this); + IReadOnlyList attrs = + _byPath.TryGetValue(nodeId ?? "", out var node) && node.Observed is { } observed + ? [new AttributeInfo(node.Segment, observed.DataType.ToString(), IsArray: false, observed.Snippet)] + : []; + Touch(); + return Task.FromResult(attrs); + } + + /// + /// Best-effort teardown. Safe on a session that was never opened (test seam, or an + /// OpenAsync that failed between constructing the client and connecting it) and + /// idempotent under the reaper racing the picker's own close. + /// + /// A task that represents the asynchronous teardown. + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) return; + + if (_client is { } client) + { + try + { + using var cts = new CancellationTokenSource(DisconnectTimeout); + await client.DisconnectAsync(new MqttClientDisconnectOptions(), cts.Token).ConfigureAwait(false); + } + catch (Exception ex) + { + // The broker may already be gone, or never have answered the CONNECT at all. + _logger?.LogDebug(ex, "MQTT browse session disconnect failed; client abandoned."); + } + + try { client.Dispose(); } + catch (Exception ex) { _logger?.LogDebug(ex, "MQTT browse client dispose failed."); } + } + + _byPath.Clear(); + _roots.Clear(); + } + + /// + /// Records one observed topic (and its payload) into the accumulating tree. Called from + /// MQTTnet's dispatcher thread, so it does no I/O, takes no lock, and never throws — a + /// throwing handler would stall the client's pump. + /// + /// The topic the message arrived on. + /// The message body; used only for type inference and the display snippet. + internal void Observe(string topic, ReadOnlySpan payload) + { + if (Disposed || string.IsNullOrWhiteSpace(topic)) return; + + var segments = topic.Split('/'); + var level = _roots; + TopicNode? node = null; + var path = string.Empty; + + foreach (var segment in segments) + { + path = path.Length == 0 ? segment : path + "/" + segment; + + if (!level.TryGetValue(segment, out var child)) + { + if (Volatile.Read(ref _nodeCount) >= _nodeCap) + { + Volatile.Write(ref _truncated, 1); + return; // stop recording; already-recorded topics keep serving + } + + var created = new TopicNode(path, segment); + child = level.GetOrAdd(segment, created); + if (ReferenceEquals(child, created)) + { + Interlocked.Increment(ref _nodeCount); + _byPath[path] = child; + } + } + + node = child; + level = child.Children; + } + + node?.Record(InferPayload(payload)); + } + + /// Test seam over that takes the payload as text. + /// The observed topic. + /// The message body as text, or for an empty payload. + internal void ObserveTopicForTest(string topic, string? payload = null) => + Observe(topic, payload is null ? ReadOnlySpan.Empty : Encoding.UTF8.GetBytes(payload)); + + /// + /// The one and only outbound-message seam. Nothing in P1 calls it — browse against a + /// live plant broker is strictly read-only, and is the + /// assertion of that. P2's Sparkplug RequestRebirthAsync (Task 23) is the single + /// explicitly-operator-triggered exception and must route through here rather than + /// touching directly, or the guarantee stops being checkable. + /// + /// The message to publish. + /// Cancellation token for the publish. + /// A task that represents the asynchronous publish. + private async Task PublishAsync(MqttApplicationMessage message, CancellationToken cancellationToken) + { + Interlocked.Increment(ref _publishCount); + if (_client is { } client) + { + await client.PublishAsync(message, cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Best-effort type inference from a payload body. Explicit authoring stays the authority + /// (design §4) — this only decorates the picker. Non-UTF-8 bodies are reported as opaque. + /// + /// The raw message body. + /// The inferred type and a bounded display snippet. + private static ObservedValue InferPayload(ReadOnlySpan payload) + { + if (payload.IsEmpty) return new ObservedValue(DriverDataType.String, "(empty)"); + + string text; + try + { + // Throw-on-invalid so genuinely binary payloads are reported as such rather than + // silently rendered as a field of replacement characters. + text = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true) + .GetString(payload); + } + catch (DecoderFallbackException) + { + return new ObservedValue(DriverDataType.String, $"({payload.Length} bytes, binary)"); + } + + var trimmed = text.Trim(); + var snippet = trimmed.Length > PayloadSnippetMaxChars + ? string.Concat(trimmed.AsSpan(0, PayloadSnippetMaxChars), "…") + : trimmed; + + // Ordered narrowest-first. "1" infers Int64 rather than Boolean: an integer reading is the + // commoner meaning and, unlike the reverse, does not silently collapse a range to two states. + var dataType = trimmed switch + { + _ when bool.TryParse(trimmed, out _) => DriverDataType.Boolean, + _ when long.TryParse(trimmed, NumberStyles.Integer, CultureInfo.InvariantCulture, out _) + => DriverDataType.Int64, + _ when double.TryParse(trimmed, NumberStyles.Float, CultureInfo.InvariantCulture, out _) + => DriverDataType.Float64, + _ => DriverDataType.String, + }; + + return new ObservedValue(dataType, snippet); + } + + /// Projects one level of the tree into browse nodes, ordered so the picker is stable. + /// The child map to project. + /// The projected nodes, sorted by segment. + private static List Project(ConcurrentDictionary level) => + level.Values + .OrderBy(n => n.Segment, StringComparer.Ordinal) + .Select(n => + { + // A childless node is always the terminal segment of a topic that actually published, + // so it is the committable address. A node that both published and has children below + // it renders as a folder — MQTT allows that shape and the picker must stay expandable. + var hasChildren = !n.Children.IsEmpty; + return new BrowseNode( + n.Path, + n.Segment, + hasChildren ? BrowseNodeKind.Folder : BrowseNodeKind.Leaf, + hasChildren); + }) + .ToList(); + + private void Touch() => Interlocked.Exchange(ref _lastUsedTicksUtc, DateTime.UtcNow.Ticks); + + /// The most recent payload observed on a topic, published as one atomic reference. + /// The type inferred from that payload. + /// A bounded, display-safe rendering of that payload. + private sealed record ObservedValue(DriverDataType DataType, string Snippet); + + /// One segment of the observed topic space. + private sealed class TopicNode(string path, string segment) + { + /// Full slash-joined topic path — the node id the picker round-trips. + public string Path { get; } = path; + + /// This node's own topic segment — the display label. + public string Segment { get; } = segment; + + /// Child segments, keyed by segment name. + public ConcurrentDictionary Children { get; } = new(StringComparer.Ordinal); + + /// + /// The last payload observed on exactly this topic, or when this is + /// a synthesised intermediate segment nothing published to. Written by the dispatcher + /// thread and read by the UI thread, so the whole record is swapped as one reference. + /// + public ObservedValue? Observed => Volatile.Read(ref _observed); + + private ObservedValue? _observed; + + /// Publishes the latest observation for this topic. + /// The inferred type + snippet to record. + public void Record(ObservedValue value) => Volatile.Write(ref _observed, value); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs new file mode 100644 index 00000000..76194cab --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs @@ -0,0 +1,172 @@ +using System.Buffers; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using MQTTnet; +using MQTTnet.Protocol; +using ZB.MOM.WW.OtOpcUa.Commons.Browsing; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser; + +/// +/// Bespoke MQTT address-picker browser: opens a short-lived, strictly passive observation +/// window against the broker named by the form's JSON. +/// +/// MQTT has no discovery protocol, so there is nothing to enumerate — the browser subscribes +/// to the wildcard (#, or {topicPrefix}/#) and lets +/// accumulate whatever arrives. Nothing on any browse path +/// publishes: an operator opening a picker must not be able to inject a message into a +/// running plant. +/// +/// +public sealed class MqttDriverBrowser : IDriverBrowser +{ + /// Floor on the open budget — a 1 s ConnectTimeoutSeconds would make browse unusable. + internal const int MinOpenBudgetSeconds = 5; + + /// Ceiling on the open budget — the picker must never hang on an unreachable broker. + internal const int MaxOpenBudgetSeconds = 30; + + /// Marks the transient browse identity in the broker's client-id/session logs. + internal const string BrowseClientIdPrefix = "-browse-"; + + /// + /// Matches the runtime driver factory's parsing so a given DriverConfig JSON means the same + /// thing to the browser as it does to the deployed driver. JsonStringEnumConverter lets + /// mode / protocolVersion be authored as their string names — the natural form + /// for AdminUI-emitted JSON — while still accepting numeric ordinals. + /// + private static readonly JsonSerializerOptions JsonOpts = new() + { + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter() }, + }; + + private readonly ILogger _logger; + + /// + /// Creates a browser. Connection-free by contract — the universal browser's + /// CanBrowse constructs a throwaway instance purely to ask which driver type it + /// handles, so the constructor must never touch the network. + /// + /// Optional logger; defaults to . Never receives credentials. + public MqttDriverBrowser(ILogger? logger = null) => + _logger = logger ?? NullLogger.Instance; + + /// + // Literal rather than a constant: DriverTypeNames.Mqtt does not exist yet — Task 9 adds it, and + // owns that file. The string must stay EXACTLY "Mqtt": a DriverType string that drifts from the + // persisted one silently breaks driver dispatch (the repo's ModbusTcp/Modbus incident). + public string DriverType => "Mqtt"; + + /// + /// + /// Connects with a browse-only client id, subscribes the wildcard at QoS 0, and hands back a + /// session that serves whatever the subscription observes. The whole open is bounded by + /// ; nothing here publishes. + /// + public async Task OpenAsync(string configJson, CancellationToken cancellationToken) + { + var opts = JsonSerializer.Deserialize(configJson, JsonOpts) + ?? throw new InvalidOperationException("Mqtt options deserialized to null."); + + if (opts.Mode == MqttMode.SparkplugB) + { + // P2 seam (Task 23): Sparkplug browse is a Group → EdgeNode → Device → Metric tree built + // from observed NBIRTH/DBIRTH payloads, with an operator-triggered rebirth request as the + // one sanctioned publish. Failing fast beats serving a raw spBv1.0 topic tree that looks + // like a metric tree but is not one. + throw new NotSupportedException( + "Sparkplug B browse (Group → EdgeNode → Device → Metric) is not available yet; " + + "the MQTT browser currently observes plain topics only. Author Sparkplug tags manually."); + } + + var filter = BuildRootFilter(opts); + var suffix = BuildBrowseClientIdSuffix(); + var clientOptions = MqttConnection.BuildClientOptions(ToBrowseOptions(opts), suffix, _logger); + + using var openCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + openCts.CancelAfter(OpenBudget(opts)); + + var client = new MqttClientFactory().CreateMqttClient(); + var session = new MqttBrowseSession(opts.Mode, client, _logger); + try + { + // Runs on MQTTnet's dispatcher: record and return, nothing else. + client.ApplicationMessageReceivedAsync += args => + { + var payload = args.ApplicationMessage.Payload; + ReadOnlySpan body = payload.IsSingleSegment ? payload.FirstSpan : payload.ToArray(); + session.Observe(args.ApplicationMessage.Topic, body); + return Task.CompletedTask; + }; + + await client.ConnectAsync(clientOptions, openCts.Token).ConfigureAwait(false); + + // QoS 0: a transient observation window has no delivery guarantee to offer and should + // cost the broker as little as possible. + var subscribeOptions = new MqttClientSubscribeOptionsBuilder() + .WithTopicFilter(f => f + .WithTopic(filter) + .WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtMostOnce)) + .Build(); + await client.SubscribeAsync(subscribeOptions, openCts.Token).ConfigureAwait(false); + + _logger.LogInformation( + "AdminUI MQTT browse session opened against {Host}:{Port} observing '{Filter}' (read-only).", + opts.Host, opts.Port, filter); + + return session; + } + catch + { + await session.DisposeAsync().ConfigureAwait(false); // owns the client — disconnects + disposes + throw; + } + } + + /// + /// The wildcard the observation window subscribes to: the whole broker (#) unless the + /// config scopes it with a Plain-mode topic prefix. + /// + /// The browse configuration. + /// The MQTT topic filter to subscribe. + internal static string BuildRootFilter(MqttDriverOptions options) + { + var prefix = (options.Plain?.TopicPrefix ?? string.Empty).Trim(); + if (prefix.Length == 0) return "#"; + if (!prefix.EndsWith('/')) prefix += "/"; + return prefix + "#"; + } + + /// + /// A unique per-session client-id suffix. A broker disconnects an existing client when a new + /// one CONNECTs with the same client id, so sharing the driver's id would knock the + /// live driver offline every time an operator opened the picker. + /// + /// The suffix appended to the configured client id. + internal static string BuildBrowseClientIdSuffix() => + BrowseClientIdPrefix + Guid.NewGuid().ToString("N")[..8]; + + /// + /// Projects the authored options into the ones the browse CONNECT uses. Clean session is + /// forced: a transient browse identity must not leave queued-message state behind on the + /// broker after the picker closes. + /// + /// The authored configuration. + /// The configuration the browse CONNECT is built from. + internal static MqttDriverOptions ToBrowseOptions(MqttDriverOptions options) => + options with { CleanSession = true }; + + /// + /// Whole-open bound (connect + subscribe), clamped to + /// around the config's + /// own connect deadline. + /// + /// The browse configuration. + /// The clamped open budget. + internal static TimeSpan OpenBudget(MqttDriverOptions options) => + TimeSpan.FromSeconds(Math.Clamp(options.ConnectTimeoutSeconds, MinOpenBudgetSeconds, MaxOpenBudgetSeconds)); +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser.csproj new file mode 100644 index 00000000..bdc8e8c6 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser.csproj @@ -0,0 +1,36 @@ + + + + net10.0 + enable + enable + latest + true + ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser + ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser + + + + + + + + + + + + + + + + + + + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowseSessionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowseSessionTests.cs new file mode 100644 index 00000000..b2a309c9 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowseSessionTests.cs @@ -0,0 +1,321 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Browsing; +using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests; + +/// +/// Covers the passive #-observation browse session and the browser that opens it. The +/// session is exercised through its test seam (ObserveTopicForTest) rather than a live +/// broker: the accumulating tree is the whole of the P1 logic, and the MQTTnet plumbing that +/// feeds it is a one-line handler. +/// +public sealed class MqttBrowseSessionTests +{ + // ---------------------------------------------------------------- tree shape + + [Fact] + public async Task ExpandAsync_BuildsTopicSegmentTree_FromObservedTopics() + { + await using var s = new MqttBrowseSession(MqttMode.Plain); + s.ObserveTopicForTest("factory/line3/oven/temp"); + + var root = await s.RootAsync(TestContext.Current.CancellationToken); + root.ShouldContain(n => n.DisplayName == "factory"); + + var lvl2 = await s.ExpandAsync("factory", TestContext.Current.CancellationToken); + lvl2.ShouldContain(n => n.DisplayName == "line3"); + + var lvl3 = await s.ExpandAsync("factory/line3", TestContext.Current.CancellationToken); + lvl3.ShouldContain(n => n.DisplayName == "oven"); + + var lvl4 = await s.ExpandAsync("factory/line3/oven", TestContext.Current.CancellationToken); + var leaf = lvl4.ShouldHaveSingleItem(); + leaf.DisplayName.ShouldBe("temp"); + leaf.NodeId.ShouldBe("factory/line3/oven/temp"); + leaf.Kind.ShouldBe(BrowseNodeKind.Leaf); + leaf.HasChildrenHint.ShouldBeFalse(); + } + + [Fact] + public async Task ObservedTopics_AccumulateAcrossMessages_AndDoNotDuplicate() + { + await using var s = new MqttBrowseSession(MqttMode.Plain); + s.ObserveTopicForTest("factory/line3/oven/temp"); + s.ObserveTopicForTest("factory/line3/oven/temp"); // repeat — must not duplicate + s.ObserveTopicForTest("factory/line3/oven/pressure"); + s.ObserveTopicForTest("factory/line4/mixer/rpm"); + s.ObserveTopicForTest("plant/power"); + + var root = await s.RootAsync(TestContext.Current.CancellationToken); + root.Select(n => n.DisplayName).ShouldBe(["factory", "plant"]); // sorted, deduped + + var lines = await s.ExpandAsync("factory", TestContext.Current.CancellationToken); + lines.Select(n => n.DisplayName).ShouldBe(["line3", "line4"]); + + var oven = await s.ExpandAsync("factory/line3/oven", TestContext.Current.CancellationToken); + oven.Select(n => n.DisplayName).ShouldBe(["pressure", "temp"]); + } + + [Fact] + public async Task IntermediateNode_IsFolder_WithChildrenHint() + { + await using var s = new MqttBrowseSession(MqttMode.Plain); + s.ObserveTopicForTest("factory/line3/oven/temp"); + + var root = await s.RootAsync(TestContext.Current.CancellationToken); + var factory = root.ShouldHaveSingleItem(); + factory.Kind.ShouldBe(BrowseNodeKind.Folder); + factory.HasChildrenHint.ShouldBeTrue(); + } + + [Fact] + public async Task ExpandAsync_UnknownNode_ReturnsEmpty() + { + await using var s = new MqttBrowseSession(MqttMode.Plain); + s.ObserveTopicForTest("factory/line3"); + + var children = await s.ExpandAsync("nope/not/here", TestContext.Current.CancellationToken); + children.ShouldBeEmpty(); + } + + [Fact] + public async Task RootAsync_BeforeAnyTraffic_IsEmpty() + { + // MQTT has no discovery protocol: an observation window that has seen nothing shows nothing. + await using var s = new MqttBrowseSession(MqttMode.Plain); + (await s.RootAsync(TestContext.Current.CancellationToken)).ShouldBeEmpty(); + } + + // ---------------------------------------------------------------- read-only guarantee + + [Fact] + public async Task BrowseCalls_PublishNothing() // browse is read-only + { + await using var s = new MqttBrowseSession(MqttMode.Plain); + s.ObserveTopicForTest("factory/line3/oven/temp", "23.5"); + + await s.RootAsync(TestContext.Current.CancellationToken); + await s.ExpandAsync("factory", TestContext.Current.CancellationToken); + await s.AttributesAsync("factory/line3/oven/temp", TestContext.Current.CancellationToken); + + s.PublishCountForTest.ShouldBe(0); + } + + // ---------------------------------------------------------------- attributes + + [Theory] + [InlineData("23.5", nameof(Core.Abstractions.DriverDataType.Float64))] + [InlineData("42", nameof(Core.Abstractions.DriverDataType.Int64))] + [InlineData("true", nameof(Core.Abstractions.DriverDataType.Boolean))] + [InlineData("{\"v\":1}", nameof(Core.Abstractions.DriverDataType.String))] + public async Task AttributesAsync_InfersTypeFromLastPayload(string payload, string expectedType) + { + await using var s = new MqttBrowseSession(MqttMode.Plain); + s.ObserveTopicForTest("factory/oven/temp", payload); + + var attrs = await s.AttributesAsync("factory/oven/temp", TestContext.Current.CancellationToken); + var a = attrs.ShouldHaveSingleItem(); + a.Name.ShouldBe("temp"); + a.DriverDataType.ShouldBe(expectedType); + a.IsArray.ShouldBeFalse(); + a.IsAlarm.ShouldBeFalse(); + a.SecurityClass.ShouldContain(payload); // the last-payload snippet + } + + [Fact] + public async Task AttributesAsync_LongPayload_IsTruncatedToASnippet() + { + await using var s = new MqttBrowseSession(MqttMode.Plain); + s.ObserveTopicForTest("t/long", new string('x', 500)); + + var a = (await s.AttributesAsync("t/long", TestContext.Current.CancellationToken)).ShouldHaveSingleItem(); + a.SecurityClass.Length.ShouldBeLessThanOrEqualTo(MqttBrowseSession.PayloadSnippetMaxChars + 1); + } + + [Fact] + public async Task AttributesAsync_UnobservedFolder_ReturnsEmpty() + { + await using var s = new MqttBrowseSession(MqttMode.Plain); + s.ObserveTopicForTest("factory/oven/temp", "1"); + + // "factory" is a synthesised path segment — nothing was ever published to it. + (await s.AttributesAsync("factory", TestContext.Current.CancellationToken)).ShouldBeEmpty(); + (await s.AttributesAsync("no/such/node", TestContext.Current.CancellationToken)).ShouldBeEmpty(); + } + + // ---------------------------------------------------------------- concurrency + + [Fact] + public async Task ObserveDuringBrowse_IsThreadSafe() + { + // Messages arrive on MQTTnet's dispatcher thread while the picker calls ExpandAsync. + await using var s = new MqttBrowseSession(MqttMode.Plain); + using var stop = new CancellationTokenSource(); + + var observed = 0; + var writer = Task.Run(() => + { + var i = 0; + while (!stop.Token.IsCancellationRequested) + { + s.ObserveTopicForTest($"factory/line{i % 8}/dev{i % 32}/m{i}", i.ToString()); + Interlocked.Increment(ref observed); + i++; + } + }, CancellationToken.None); + + // The read loop is pure in-memory work and would otherwise finish before the writer thread + // is even scheduled, leaving the two never actually overlapping. + while (Volatile.Read(ref observed) == 0) await Task.Yield(); + + for (var i = 0; i < 400; i++) + { + await s.RootAsync(TestContext.Current.CancellationToken); + await s.ExpandAsync("factory", TestContext.Current.CancellationToken); + await s.ExpandAsync("factory/line3", TestContext.Current.CancellationToken); + } + + await stop.CancelAsync(); + await writer; // rethrows anything the observer thread hit + + (await s.RootAsync(TestContext.Current.CancellationToken)).ShouldNotBeEmpty(); + } + + // ---------------------------------------------------------------- lifetime + + [Fact] + public async Task LastUsedUtc_AdvancesOnEveryCall() + { + var s = new MqttBrowseSession(MqttMode.Plain); + var before = s.LastUsedUtc; + await Task.Delay(15, TestContext.Current.CancellationToken); + + await s.RootAsync(TestContext.Current.CancellationToken); + s.LastUsedUtc.ShouldBeGreaterThan(before); + + var afterRoot = s.LastUsedUtc; + await Task.Delay(15, TestContext.Current.CancellationToken); + await s.ExpandAsync("x", TestContext.Current.CancellationToken); + s.LastUsedUtc.ShouldBeGreaterThan(afterRoot); + + await s.DisposeAsync(); + } + + [Fact] + public async Task DisposeAsync_OnNeverOpenedSession_IsIdempotentAndDoesNotThrow() + { + var s = new MqttBrowseSession(MqttMode.Plain); + await Should.NotThrowAsync(async () => await s.DisposeAsync()); + await Should.NotThrowAsync(async () => await s.DisposeAsync()); + } + + [Fact] + public async Task AfterDispose_BrowseCallsThrowObjectDisposed() + { + var s = new MqttBrowseSession(MqttMode.Plain); + s.ObserveTopicForTest("a/b"); + await s.DisposeAsync(); + + await Should.ThrowAsync( + async () => await s.RootAsync(TestContext.Current.CancellationToken)); + await Should.ThrowAsync( + async () => await s.ExpandAsync("a", TestContext.Current.CancellationToken)); + await Should.ThrowAsync( + async () => await s.AttributesAsync("a/b", TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ObserveAfterDispose_IsIgnored() + { + // A message already in MQTTnet's dispatcher when the reaper disposes the session. + var s = new MqttBrowseSession(MqttMode.Plain); + await s.DisposeAsync(); + Should.NotThrow(() => s.ObserveTopicForTest("late/arrival", "1")); + } + + // ---------------------------------------------------------------- node cap + + [Fact] + public async Task NodeCap_StopsRecording_AndSurfacesATruncationMarker() + { + await using var s = new MqttBrowseSession(MqttMode.Plain, nodeCap: 4); + s.ObserveTopicForTest("a/b/c"); // 3 nodes + s.ObserveTopicForTest("d/e/f"); // would take it past 4 + + var root = await s.RootAsync(TestContext.Current.CancellationToken); + root.ShouldContain(n => n.NodeId == MqttBrowseSession.TruncatedNodeId); + } + + // ---------------------------------------------------------------- the browser + + [Fact] + public void Ctor_IsConnectionFree_AndReportsTheMqttDriverType() + { + // The universal-browser CanBrowse pattern constructs a throwaway instance purely to ask + // what driver type it handles — the ctor must never touch the network. + var browser = new MqttDriverBrowser(); + browser.DriverType.ShouldBe("Mqtt"); + } + + [Fact] + public async Task OpenAsync_SparkplugMode_FailsFastWithoutConnecting() + { + var browser = new MqttDriverBrowser(); + const string cfg = """{"host":"broker.invalid","port":8883,"mode":"SparkplugB"}"""; + + await Should.ThrowAsync( + async () => await browser.OpenAsync(cfg, TestContext.Current.CancellationToken)); + } + + [Theory] + [InlineData(null, "#")] + [InlineData("", "#")] + [InlineData(" ", "#")] + [InlineData("factory", "factory/#")] + [InlineData("factory/", "factory/#")] + [InlineData("factory/line3", "factory/line3/#")] + public void BuildRootFilter_ScopesTheWildcardToTheConfiguredPrefix(string? prefix, string expected) + { + var opts = new MqttDriverOptions + { + Mode = MqttMode.Plain, + Plain = prefix is null ? null : new MqttPlainOptions { TopicPrefix = prefix }, + }; + MqttDriverBrowser.BuildRootFilter(opts).ShouldBe(expected); + } + + [Fact] + public void BrowseClientIdSuffix_IsUnique_AndIsAppliedToTheConnectClientId() + { + // A broker disconnects an existing client when a new one CONNECTs with the same id — + // browsing must never knock the running driver offline. + var a = MqttDriverBrowser.BuildBrowseClientIdSuffix(); + var b = MqttDriverBrowser.BuildBrowseClientIdSuffix(); + a.ShouldNotBe(b); + a.ShouldStartWith("-browse-"); + a.Length.ShouldBe("-browse-".Length + 8); + + var opts = new MqttDriverOptions { ClientId = "otopcua-node1" }; + MqttConnection.BuildClientOptions(opts, a).ClientId.ShouldBe("otopcua-node1" + a); + } + + [Fact] + public void BrowseConnect_ForcesACleanSession() + { + // A transient browse identity must not leave queued-message state behind on the broker. + var opts = new MqttDriverOptions { CleanSession = false }; + MqttDriverBrowser.ToBrowseOptions(opts).CleanSession.ShouldBeTrue(); + } + + [Theory] + [InlineData(1, 5)] // clamped up + [InlineData(15, 15)] + [InlineData(600, 30)] // clamped down + public void OpenBudget_IsClampedTo5To30Seconds(int configured, int expected) + { + var opts = new MqttDriverOptions { ConnectTimeoutSeconds = configured }; + MqttDriverBrowser.OpenBudget(opts).ShouldBe(TimeSpan.FromSeconds(expected)); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests.csproj index 2ad93142..f1a88765 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests.csproj +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests.csproj @@ -22,6 +22,7 @@ + From f2a9ee5d93ed1d466aa2cd199cdd0b08da0a33e4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 15:46:23 -0400 Subject: [PATCH 12/43] docs(mqtt): flag the Last-Will landmine at the browse-options seam A will is published by the BROKER, so it is invisible to PublishCountForTest. MqttDriverOptions carries none today, but Sparkplug NDEATH is a will message - once P2 adds it, an ungraceful browse disconnect would fire NDEATH under the plant's own edge-node identity. ToBrowseOptions is where it must be cleared. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../MqttDriverBrowser.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs index 76194cab..ee87b9b4 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs @@ -154,6 +154,15 @@ public sealed class MqttDriverBrowser : IDriverBrowser /// Projects the authored options into the ones the browse CONNECT uses. Clean session is /// forced: a transient browse identity must not leave queued-message state behind on the /// broker after the picker closes. + /// + /// ⚠ P2 landmine — a Last Will would break the read-only guarantee from outside it. + /// MqttDriverOptions carries no will today, so there is nothing to strip. When the + /// Sparkplug tasks add one (NDEATH is a will message), it MUST be cleared here: a + /// will is published by the broker, not by us, so it is invisible to + /// — and any ungraceful end to a browse + /// session (crash, dropped link, the disconnect deadline expiring) would then fire an + /// NDEATH under the plant's own edge-node identity. + /// /// /// The authored configuration. /// The configuration the browse CONNECT is built from. From d421487bcde334b6add8ad6a890de424ca09a303 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 16:10:07 -0400 Subject: [PATCH 13/43] =?UTF-8?q?feat(mqtt):=20plain=20subscribe=E2=86=92O?= =?UTF-8?q?nDataChange=20with=20retained=20seed=20+=20ref=20resolver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MqttSubscriptionManager is the P1 plain-MQTT ingest path: it holds the authored RawPath → MqttTagDefinition table behind the shared EquipmentTagRefResolver, establishes one SUBSCRIBE per distinct authored topic, and turns each inbound message into a LastValueCache update plus an OnDataChange notification. Load-bearing choices, each pinned by a falsified test: - The published reference IS the RawPath (def.Name), never the topic and never the TagConfig blob — DriverHostActor's dual-namespace fan-out is RawPath-keyed, so any other key passes every unit test and delivers nothing in production. - One message fans out to EVERY tag on its topic; several tags routinely share a topic with different jsonPaths, and stopping at the first match silently starves the rest. - An unauthored topic raises nothing: MQTT ingest never auto-provisions. - Wildcard-authored topics (accepted by the parser, warned at deploy) match via MQTTnet's own MqttTopicFilterComparer, so '+'/'#' behave as a broker would. Concrete topics stay a single lookup; the wildcard scan runs only when one exists. - Retained seeds are honoured natively on MQTT 5.0 (retain handling) AND filtered client-side on the retain flag, because 3.1.1 brokers always replay. - Nothing throws on MQTTnet's dispatcher thread: a malformed payload degrades that tag (BadDecodingError / BadTypeMismatch), and a throwing OnDataChange subscriber is contained without starving the tags behind it. - The manager issues the FIRST subscribe itself — Reconnected deliberately does not fire after the initial ConnectAsync. - Reconnect re-subscribe: total failure THROWS (MqttConnection then tears the session down and retries under backoff, rather than serving a connected-but-deaf driver); a partial SUBACK rejection degrades only the denied refs, because retrying forever over one ACL-denied topic would take every tag dark. MqttConnection gains a MessageReceived observer (fired on the dispatcher, throwing observers contained) and a bounded SubscribeAsync under its own linked-CTS deadline — deliberately not MqttClientOptions.Timeout, which already governs every MQTTnet op. A refused filter is an outcome, not an exception; only the SUBSCRIBE itself failing throws. Classify() is parameterised by operation name (messages unchanged for connect). JSONPath is a documented subset ($, $.a.b, $['a'], $.a[0]); filters/slices/ recursive descent report a miss rather than pulling in a JSONPath package for an expression form that selects a set where a tag needs one scalar. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../MqttConnection.cs | 225 +++- .../MqttSubscriptionManager.cs | 1038 +++++++++++++++++ .../MqttSubscriptionManagerTests.cs | 607 ++++++++++ 3 files changed, 1861 insertions(+), 9 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttSubscriptionManager.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttSubscriptionManagerTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs index 02f81e7d..1ed55064 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs @@ -1,11 +1,67 @@ +using System.Buffers; using System.Net.Security; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using Microsoft.Extensions.Logging; using MQTTnet; +using MQTTnet.Protocol; namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt; +/// +/// Receives one inbound MQTT application message. Invoked on MQTTnet's own dispatcher +/// thread: an implementation must not block, must not do I/O and must not throw, or it stalls +/// the client's pump for every other subscription. +/// +/// The concrete topic the message arrived on (never a filter). +/// +/// The message body. Only valid for the duration of the call — the underlying buffer belongs to +/// MQTTnet, so anything an implementation wants to keep must be copied out. +/// +/// +/// The message's MQTT retain flag as delivered to this client, i.e. true for +/// the broker's stored last-known value replayed at subscribe time, and false for an +/// ordinary live publish. This is the retained seed signal. +/// +public delegate void MqttMessageObserver(string topic, ReadOnlySpan payload, bool retained); + +/// One topic filter to SUBSCRIBE, as the subscription manager wants it established. +/// The topic filter (concrete, or carrying MQTT wildcards). +/// Requested QoS, 0–2. +/// +/// Whether the broker should replay its retained message for this filter at subscribe time. +/// Honoured natively on MQTT 5.0 (retain handling); on 3.1.1 the broker always replays and the +/// manager drops the seed client-side instead. +/// +public sealed record MqttTopicSubscription(string Topic, int Qos, bool SeedRetained); + +/// The broker's SUBACK verdict for one requested filter. +/// The filter this outcome answers. +/// Whether the broker granted the subscription. +/// The broker's reason code / string, for logs and diagnostics. +public sealed record MqttSubscribeOutcome(string Topic, bool Granted, string Reason); + +/// +/// The one seam through which the subscription manager establishes MQTT subscriptions. +/// is the production implementation; the interface exists so the +/// manager's SUBACK handling (per-filter grant / rejection, total failure) is exercisable without +/// a broker. +/// +public interface IMqttSubscribeTransport +{ + /// + /// Issues one SUBSCRIBE carrying every filter and returns the broker's per-filter verdict. + /// Implementations must be bounded — a broker that accepts SUBSCRIBE and never SUBACKs must + /// fail at a deadline, not hang. + /// + /// The filters to establish; never empty. + /// Caller cancellation, linked with the implementation's deadline. + /// One outcome per requested filter. + Task> SubscribeAsync( + IReadOnlyList filters, + CancellationToken cancellationToken); +} + /// Lifecycle state of an . public enum MqttConnectionState { @@ -144,10 +200,10 @@ public enum MqttConnectionState /// socket no later dispose could ever reach. Now the losing side of that race disposes the /// client it created and reports . /// -/// Subscription (Task 6) and Sparkplug rebirth-on-reconnect (Task 21, which hangs off -/// ) are deliberately not implemented here. +/// Sparkplug rebirth-on-reconnect (Task 21, which hangs off ) is +/// deliberately not implemented here. /// -public sealed class MqttConnection : IAsyncDisposable +public sealed class MqttConnection : IAsyncDisposable, IMqttSubscribeTransport { private readonly string _driverId; @@ -217,6 +273,19 @@ public sealed class MqttConnection : IAsyncDisposable /// public event Func? Reconnected; + /// + /// Raised for every inbound application message, on MQTTnet's own dispatcher thread. This + /// connection does no routing, parsing or typing of its own — that is the subscription + /// manager's job; here the message is only stamped onto and + /// handed on. + /// + /// + /// Handlers must not block, do I/O or throw. A throwing handler is caught and logged here + /// rather than being allowed to escape into the library's pump — one bad tag must not stop + /// delivery for every other subscriber. + /// + public event MqttMessageObserver? MessageReceived; + /// Whether the underlying client currently holds an established MQTT session. public bool IsConnected => _client?.IsConnected ?? false; @@ -572,20 +641,28 @@ public sealed class MqttConnection : IAsyncDisposable } /// - /// Maps a connect failure onto this type's documented contract. MQTTnet wraps a cancelled - /// connect in MqttConnectingFailedException rather than letting the + /// Maps a bounded-operation failure onto this type's documented contract. MQTTnet wraps a + /// cancelled connect in MqttConnectingFailedException rather than letting the /// surface, so the legs are told apart by which /// token fired, not by exception type. Precedence: teardown, then caller intent, then the /// deadline. /// - private Exception Classify(Exception ex, CancellationToken cancellationToken, CancellationTokenSource deadline) + /// The failure to classify. + /// The caller's token — cancelled means caller intent. + /// The operation's own deadline source. + /// The operation name for the message ("connect", "subscribe"). + private Exception Classify( + Exception ex, + CancellationToken cancellationToken, + CancellationTokenSource deadline, + string operation = "connect") { if (Disposed) { return new ObjectDisposedException( nameof(MqttConnection), new InvalidOperationException( - $"MQTT driver '{_driverId}': connect to {_options.Host}:{_options.Port} was aborted because the " + $"MQTT driver '{_driverId}': {operation} to {_options.Host}:{_options.Port} was aborted because the " + "connection was disposed while the attempt was in flight.", ex)); } @@ -593,7 +670,7 @@ public sealed class MqttConnection : IAsyncDisposable if (cancellationToken.IsCancellationRequested) { return new OperationCanceledException( - $"MQTT driver '{_driverId}': connect to {_options.Host}:{_options.Port} was cancelled.", + $"MQTT driver '{_driverId}': {operation} to {_options.Host}:{_options.Port} was cancelled.", ex, cancellationToken); } @@ -601,7 +678,7 @@ public sealed class MqttConnection : IAsyncDisposable if (deadline.IsCancellationRequested) { return new TimeoutException( - $"MQTT driver '{_driverId}': connect to {_options.Host}:{_options.Port} did not complete within " + $"MQTT driver '{_driverId}': {operation} to {_options.Host}:{_options.Port} did not complete within " + $"{_options.ConnectTimeoutSeconds}s.", ex); } @@ -640,9 +717,139 @@ public sealed class MqttConnection : IAsyncDisposable private Task OnApplicationMessageReceivedAsync(MqttApplicationMessageReceivedEventArgs args) { Interlocked.Exchange(ref _lastMessageTicksUtc, DateTime.UtcNow.Ticks); + + var observers = MessageReceived; + if (observers is null) + { + return Task.CompletedTask; + } + + var message = args.ApplicationMessage; + var payload = message.Payload; + + // Same idiom the browse session uses: the common single-segment case is served straight off + // the library's buffer; a fragmented sequence is flattened once. Either way the span is only + // valid for this call, which is exactly what MqttMessageObserver documents. + ReadOnlySpan body = payload.IsSingleSegment ? payload.FirstSpan : payload.ToArray(); + + try + { + observers(message.Topic, body, message.Retain); + } + catch (Exception ex) + { + // An exception escaping here surfaces inside MQTTnet's own pump. Contain it: a broken + // observer must degrade its own tags, never stop delivery for every other subscription. + _logger?.LogError( + ex, + "MQTT driver '{DriverId}': an inbound-message observer threw; the message was dropped.", + _driverId); + } + return Task.CompletedTask; } + /// + /// Issues one SUBSCRIBE carrying every requested filter, bounded by + /// so a broker that accepts SUBSCRIBE and + /// never answers SUBACK — the frozen-peer shape again — fails at the deadline instead of + /// parking the caller (and, on the reconnect path, the supervisor) forever. + /// + /// + /// The deadline is this method's own linked source, deliberately not + /// : that knob already governs every MQTTnet operation + /// and repurposing it would couple the subscribe budget to the connect budget in a way neither + /// side could change independently. + /// + /// A rejected filter is not an exception: the returned outcomes carry the broker's + /// per-filter verdict so the caller can degrade exactly the affected references. Only a + /// failure of the SUBSCRIBE itself (transport error, deadline, teardown) throws. + /// + /// + /// The filters to establish. + /// Caller cancellation; linked with the subscribe deadline. + /// One outcome per requested filter, in request order. + /// The subscribe deadline elapsed. + /// was cancelled. + /// The connection was disposed. + /// There is no established session to subscribe on. + public async Task> SubscribeAsync( + IReadOnlyList filters, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(filters); + ObjectDisposedException.ThrowIf(Disposed, this); + + if (filters.Count == 0) + { + return []; + } + + var client = _client + ?? throw new InvalidOperationException( + $"MQTT driver '{_driverId}': cannot subscribe before a session to {_options.Host}:{_options.Port} " + + "has been established."); + + var builder = new MqttClientSubscribeOptionsBuilder(); + foreach (var filter in filters) + { + builder = builder.WithTopicFilter(f => f + .WithTopic(filter.Topic) + .WithQualityOfServiceLevel(MapQos(filter.Qos)) + // Only meaningful on MQTT 5.0. On 3.1.1 the broker always replays its retained + // message, so the manager also drops unwanted seeds client-side off the retain flag. + .WithRetainHandling(filter.SeedRetained + ? MqttRetainHandling.SendAtSubscribe + : MqttRetainHandling.DoNotSendOnSubscribe)); + } + + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(_options.ConnectTimeoutSeconds)); + using var linked = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + deadline.Token, + _lifetimeCts.Token); + + MqttClientSubscribeResult result; + try + { + result = await client.SubscribeAsync(builder.Build(), linked.Token).ConfigureAwait(false); + } + catch (Exception ex) + { + throw Classify(ex, cancellationToken, deadline, operation: "subscribe"); + } + + // Pair each requested filter with its SUBACK item positionally: MQTT guarantees SUBACK reason + // codes arrive in the order of the SUBSCRIBE's filters. A short/absent SUBACK (a + // specification-violating broker) is reported as ungranted rather than silently assumed good. + var items = result.Items as IList ?? [.. result.Items]; + var outcomes = new List(filters.Count); + for (var i = 0; i < filters.Count; i++) + { + if (i >= items.Count) + { + outcomes.Add(new MqttSubscribeOutcome(filters[i].Topic, Granted: false, "NoSubAckReasonCode")); + continue; + } + + var code = items[i].ResultCode; + var granted = code is MqttClientSubscribeResultCode.GrantedQoS0 + or MqttClientSubscribeResultCode.GrantedQoS1 + or MqttClientSubscribeResultCode.GrantedQoS2; + outcomes.Add(new MqttSubscribeOutcome(filters[i].Topic, granted, code.ToString())); + } + + return outcomes; + } + + /// Maps a configured QoS integer onto MQTTnet's enum, clamping an out-of-range value. + private static MqttQualityOfServiceLevel MapQos(int qos) => qos switch + { + <= 0 => MqttQualityOfServiceLevel.AtMostOnce, + 1 => MqttQualityOfServiceLevel.AtLeastOnce, + _ => MqttQualityOfServiceLevel.ExactlyOnce, + }; + /// /// The reconnect loop MQTTnet v5 no longer provides. Started on the first successful connect /// and stopped by cancelling the lifetime token; it owns every diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttSubscriptionManager.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttSubscriptionManager.cs new file mode 100644 index 00000000..8e8aa1e9 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttSubscriptionManager.cs @@ -0,0 +1,1038 @@ +using System.Collections.Concurrent; +using System.Collections.Frozen; +using System.Globalization; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using MQTTnet; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt; + +/// +/// The plain-MQTT ingest path: holds the driver's authored RawPath → +/// table, establishes one broker subscription per distinct authored +/// topic, and turns each inbound message into notifications plus a +/// update. +/// +/// +/// +/// The published reference is the RawPath. Every this +/// type raises carries — the tag's RawPath — as its +/// , never the topic and never the authored +/// TagConfig blob. DriverHostActor's dual-namespace fan-out is keyed by RawPath; +/// publishing under any other key produces a driver that passes every unit test and delivers +/// nothing at all in production. +/// +/// +/// A message fans out to EVERY matching tag. Several tags routinely share one topic +/// (one JSON document, one JSONPath per signal), so the topic index maps a topic to a +/// list of definitions and delivery walks all of them. Stopping at the first match is +/// the silent-starvation bug this shape exists to prevent, and it is pinned by a test. +/// +/// +/// Nothing here may throw on MQTTnet's dispatcher thread. +/// runs on it: extraction failures degrade the offending +/// tag to a Bad status code, a throwing downstream subscriber +/// is caught (and the tags behind it in the fan-out are still delivered), and no path does I/O +/// or takes a lock. +/// +/// +/// Unauthored topics are ignored. A wildcard-authored tag, or simply a chatty broker, +/// delivers messages this driver never asked for. They are dropped: MQTT ingest never +/// auto-provisions a tag the operator did not author. +/// +/// +/// Wildcards are matched for real. An authored topic carrying + / # is +/// accepted by the parser (warned at deploy, see MqttTagDefinitionFactory.Inspect), so +/// matching uses MQTTnet's own rather than string +/// equality — the same comparer a broker applies. Concrete topics stay a single dictionary +/// lookup; the linear wildcard scan runs only when a wildcard tag actually exists. +/// +/// +/// Sparkplug seam. P1 ingests plain topics only. Sparkplug B (Task 21) decodes a +/// protobuf payload and resolves metrics by the stable +/// (group, node, device, metricName) tuple rather than by topic; it belongs in its own +/// handler alongside this one, feeding the same + +/// sinks. Nothing Sparkplug-shaped is built here. +/// +/// +public sealed class MqttSubscriptionManager +{ + // OPC UA status codes (values verified against Opc.Ua.StatusCodes, not recalled). + private const uint StatusGood = 0x00000000u; + + /// The payload could not be decoded, or the JSONPath selected nothing. + private const uint StatusBadDecodingError = 0x80070000u; + + /// The payload decoded but does not fit the tag's declared dataType. + private const uint StatusBadTypeMismatch = 0x80740000u; + + /// The subscribed reference is not an authored MQTT tag. + private const uint StatusBadNodeIdUnknown = 0x80340000u; + + /// The broker refused, or never answered, the SUBSCRIBE for this reference's topic. + private const uint StatusBadCommunicationError = 0x80050000u; + + private readonly int _defaultQos; + private readonly string _driverId; + private readonly ILogger? _logger; + private readonly MqttMode _mode; + + /// RawPath → the handle of the subscription currently covering it. + private readonly ConcurrentDictionary _handleByRawPath = new(StringComparer.Ordinal); + + /// Handle → the references it was created for, so unsubscribe can undo exactly its own. + private readonly ConcurrentDictionary _refsByHandle = new(); + + /// Topics already SUBSCRIBEd on the live session — the dedupe set and the re-subscribe set. + private readonly ConcurrentDictionary _subscribedTopics = new(StringComparer.Ordinal); + + /// RawPaths whose extraction failure has already been logged loudly once. + private readonly ConcurrentDictionary _warnedRefs = new(StringComparer.Ordinal); + + private readonly EquipmentTagRefResolver _resolver; + + private long _handleSeq; + + /// The authored table + its topic index, swapped atomically by . + private volatile AuthoredTable _authored = AuthoredTable.Empty; + + private IMqttSubscribeTransport? _transport; + + /// Initializes a new instance of the class. + /// Driver options — supplies the ingest mode and the default QoS. + /// Driver instance id, used only for log correlation. + /// + /// The SUBSCRIBE seam. records subscriptions without dialling a broker + /// (the shape tests use, and the shape a not-yet-connected driver is in); + /// supplies the live connection. + /// + /// Optional logger; never receives credentials or payload bodies. + /// + /// The last-value store backing IReadable. Defaults to a fresh one owned by this + /// manager and exposed as . + /// + public MqttSubscriptionManager( + MqttDriverOptions options, + string driverId, + IMqttSubscribeTransport? transport = null, + ILogger? logger = null, + LastValueCache? cache = null) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentException.ThrowIfNullOrWhiteSpace(driverId); + + _mode = options.Mode; + _defaultQos = options.Plain?.DefaultQos ?? 1; + _driverId = driverId; + _transport = transport; + _logger = logger; + Values = cache ?? new LastValueCache(); + _resolver = new EquipmentTagRefResolver( + rawPath => _authored.ByRawPath.TryGetValue(rawPath, out var def) ? def : null); + } + + /// + public event EventHandler? OnDataChange; + + /// + /// The last observed value per RawPath — the bridge from MQTT's push model to the OPC UA + /// server's polled IReadable.ReadAsync batches. + /// + public LastValueCache Values { get; } + + /// + /// Wires this manager onto a live connection: inbound messages route to + /// and every reconnect re-establishes the subscriptions through + /// . The connection also becomes this manager's SUBSCRIBE + /// transport. + /// + /// + /// The manager does not own the connection's lifetime and never detaches — the driver disposes + /// both together. Call once per connection, and before the first + /// : a subscribe issued with no transport attached records its + /// intent (and warns) but sends nothing to a broker, and would only be established if the + /// connection later dropped and recovered. + /// + /// The connection to observe. + public void AttachTo(MqttConnection connection) + { + ArgumentNullException.ThrowIfNull(connection); + + _transport = connection; + connection.MessageReceived += HandleMessage; + connection.Reconnected += OnReconnectedAsync; + } + + /// + /// Replaces the authored tag table with the deploy's raw tags, rebuilding the topic index. + /// Wholesale, not additive: a redeploy that drops a tag must stop feeding it. + /// + /// + /// A whose TagConfig does not map is skipped with a warning, + /// never thrown — one bad tag must not fail the whole driver's initialise. Such a reference + /// then resolves to nothing and surfaces BadNodeIdUnknown, which is the stated contract + /// of MqttTagDefinitionFactory.FromTagConfig. + /// + /// The authored raw tags delivered by the deploy artifact. + /// How many entries mapped to a usable definition. + public int Register(IEnumerable rawTags) + { + ArgumentNullException.ThrowIfNull(rawTags); + + var byRawPath = new Dictionary(StringComparer.Ordinal); + foreach (var entry in rawTags) + { + if (MqttTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def)) + { + byRawPath[entry.RawPath] = def; + } + else + { + _logger?.LogWarning( + "MQTT driver '{DriverId}': tag config did not map to a definition; skipping. RawPath={RawPath}", + _driverId, + entry.RawPath); + } + } + + _authored = AuthoredTable.Build(byRawPath); + _warnedRefs.Clear(); + return byRawPath.Count; + } + + /// Resolves a RawPath to its authored definition through the shared v3 resolver. + /// The driver wire reference. + /// The definition, when this returns . + /// when the reference is an authored MQTT tag. + public bool TryResolve(string rawPath, out MqttTagDefinition def) => _resolver.TryResolve(rawPath, out def!); + + /// + /// Subscribes a batch of RawPath references: records them against a new handle and issues one + /// SUBSCRIBE for every distinct authored topic not already established. + /// + /// + /// + /// This is the first subscribe. deliberately + /// does not fire after the initial ConnectAsync, so the manager must issue the + /// opening SUBSCRIBE itself; only re-establishes it later. + /// + /// + /// Never throws, never hangs. A reference that is not an authored tag is recorded as + /// BadNodeIdUnknown; a SUBACK rejection, transport failure or subscribe deadline + /// degrades exactly the affected references to BadCommunicationError. Either way a + /// handle is returned — the OPC UA server's subscribe call must not fail because one topic + /// is ACL-denied. Recovery for a transient failure comes from the reconnect path; + /// a persistent broker-side refusal is visible as a Bad status on the affected nodes. + /// + /// + /// The RawPath references to subscribe. + /// + /// Ignored. MQTT is push-based — the broker's publish rate is the notification rate, and this + /// driver neither polls nor throttles. + /// + /// Cancellation for the SUBSCRIBE round-trip. + /// The handle every resulting is attributed to. + public async Task SubscribeAsync( + IReadOnlyList fullReferences, + TimeSpan publishingInterval, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(fullReferences); + + var defs = new List(fullReferences.Count); + var now = DateTime.UtcNow; + foreach (var reference in fullReferences) + { + if (_resolver.TryResolve(reference, out var def)) + { + defs.Add(def); + continue; + } + + // Not an authored MQTT tag. Say so, rather than leaving it in "waiting for initial data" + // forever — a reference that will never be fed is a different fault from one that has + // simply not been fed yet. + _logger?.LogWarning( + "MQTT driver '{DriverId}': subscribe reference '{RawPath}' is not an authored MQTT tag.", + _driverId, + reference); + Values.Update(reference, new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now)); + } + + var handle = new MqttSubscriptionHandle(BuildDiagnosticId(defs)); + _refsByHandle[handle] = [.. defs.Select(d => d.Name)]; + foreach (var def in defs) + { + _handleByRawPath[def.Name] = handle; + } + + // One SUBSCRIBE per distinct topic, and only for topics not already live: re-SUBSCRIBEing an + // established filter is legal but pointless, and it re-triggers the retained seed. + var pending = defs.Where(d => !_subscribedTopics.ContainsKey(d.Topic)).ToList(); + await EstablishAsync(BuildFilters(pending, _defaultQos), cancellationToken).ConfigureAwait(false); + + return handle; + } + + /// + /// Stops attributing notifications to . References another live + /// subscription also covers keep publishing. + /// + /// + /// The broker-side subscription is deliberately left in place: MQTT UNSUBSCRIBE is per-filter, + /// several handles can share one filter, and an idle filter costs only the traffic a topic + /// already produces. The session teardown drops them all. + /// + /// The handle returned by . + /// Unused; present for the ISubscribable shape. + /// A completed task. + public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) + { + if (handle is not null && _refsByHandle.TryRemove(handle, out var refs)) + { + foreach (var rawPath in refs) + { + // Only clear the mapping if it is still OURS — a later overlapping subscription may + // have taken the reference over, and it must keep publishing. + _handleByRawPath.TryRemove(new KeyValuePair(rawPath, handle)); + } + } + + return Task.CompletedTask; + } + + /// + /// Re-establishes every live subscription after a reconnect. Wired to + /// by . + /// + /// + /// + /// Idempotent — re-SUBSCRIBEing an already-subscribed filter is expected on a + /// persistent session and is harmless; missing one is a healthy-looking client that + /// receives nothing forever. + /// + /// + /// Total failure THROWS, partial rejection does not. This is a deliberate split. + /// 's contract is that a throwing Reconnected subscriber + /// tears the fresh session down and retries under backoff — the correct response to "no + /// subscription was re-established", because the alternative is serving a connected, + /// permanently deaf driver. But if the broker granted some filters and refused others + /// (an ACL denying one topic), tearing the session down would retry forever and take + /// every tag dark to punish one; those references degrade to Bad instead and the + /// rest keep flowing. That is the plan's "SUBACK failure → per-ref Bad, not hang", scoped + /// to the case where it does not cost the whole driver. + /// + /// + /// The connection's lifetime token; cancelled by its dispose. + /// A task that completes when the re-subscribe has been answered. + /// + /// The subscribe failed outright, or every filter was refused — the caller is expected to tear + /// the session down and retry. + /// + public async Task OnReconnectedAsync(CancellationToken cancellationToken) + { + var authored = _authored; + var live = _subscribedTopics.Keys + .Select(topic => authored.ByTopic.TryGetValue(topic, out var defs) ? defs : []) + .SelectMany(defs => defs) + .ToList(); + + var filters = BuildFilters(live, _defaultQos); + if (filters.Count == 0) + { + return; + } + + var granted = await EstablishAsync(filters, cancellationToken).ConfigureAwait(false); + if (granted == 0) + { + throw new InvalidOperationException( + $"MQTT driver '{_driverId}': no subscription could be re-established after reconnect " + + $"({filters.Count} filter(s) requested); tearing the session down rather than serving a " + + "connection that receives nothing."); + } + } + + /// + /// Routes one inbound message to every authored tag whose topic matches, extracts the value, + /// updates , and raises for the tags that are + /// currently subscribed. + /// + /// + /// Runs on MQTTnet's dispatcher thread: no I/O, no locks, no allocation beyond the extracted + /// value, and no path throws. A tag whose payload is malformed degrades to a Bad status + /// code and the fan-out continues to the next tag. + /// + /// The concrete topic the message arrived on. + /// The message body; valid only for the duration of this call. + /// The message's retain flag — true is the broker's subscribe-time seed. + public void HandleMessage(string topic, ReadOnlySpan payload, bool retained) + { + if (string.IsNullOrEmpty(topic)) + { + return; + } + + var authored = _authored; + var now = DateTime.UtcNow; + + // Concrete topics — the overwhelmingly common case — cost one lookup, so a chatty broker + // publishing thousands of topics this driver does not care about stays cheap. + if (authored.ByTopic.TryGetValue(topic, out var exact)) + { + foreach (var def in exact) + { + Deliver(def, payload, retained, now); + } + } + + // A published topic name may not itself contain a wildcard, so a wildcard-authored definition + // can never also be an exact-index hit: no tag is delivered twice. + foreach (var def in authored.Wildcards) + { + if (MqttTopicFilterComparer.Compare(topic, def.Topic) == MqttTopicFilterCompareResult.IsMatch) + { + Deliver(def, payload, retained, now); + } + } + } + + /// + /// Collapses a set of definitions into the filters to SUBSCRIBE: one per distinct topic, + /// carrying the strongest QoS any tag on that topic asked for (so no tag silently gets a + /// weaker delivery guarantee than it was authored with), and seeding from the retained message + /// if any tag on the topic wants it (tags that do not are filtered on the retain flag at + /// delivery). + /// + /// The definitions to collapse. + /// The driver-level QoS applied to tags that authored none. + /// One filter per distinct topic. + internal static IReadOnlyList BuildFilters( + IEnumerable defs, + int defaultQos) + { + var byTopic = new Dictionary(StringComparer.Ordinal); + foreach (var def in defs) + { + var qos = def.Qos ?? defaultQos; + byTopic[def.Topic] = byTopic.TryGetValue(def.Topic, out var existing) + ? existing with + { + Qos = Math.Max(existing.Qos, qos), + SeedRetained = existing.SeedRetained || def.RetainSeed, + } + : new MqttTopicSubscription(def.Topic, qos, def.RetainSeed); + } + + return [.. byTopic.Values]; + } + + /// + /// Issues the SUBSCRIBE and applies its verdict: granted topics join the live set, refused ones + /// degrade their references to BadCommunicationError. + /// + /// The filters to establish; an empty list is a no-op. + /// Cancellation for the round-trip. + /// How many filters the broker granted. + private async Task EstablishAsync( + IReadOnlyList filters, + CancellationToken cancellationToken) + { + if (filters.Count == 0) + { + return 0; + } + + var transport = _transport; + if (transport is null) + { + // No transport: the intent is recorded so the reconnect path establishes it, but NOTHING + // reaches a broker now. Loud, because outside tests this means the composition order is + // wrong — AttachTo must run before the OPC UA server's first subscribe, or these topics + // stay unestablished until the connection happens to drop and recover. + foreach (var filter in filters) + { + _subscribedTopics[filter.Topic] = 0; + } + + _logger?.LogWarning( + "MQTT driver '{DriverId}': {FilterCount} subscription(s) recorded with no connection attached; " + + "nothing was sent to a broker. Call AttachTo before subscribing.", + _driverId, + filters.Count); + + return filters.Count; + } + + IReadOnlyList outcomes; + try + { + outcomes = await transport.SubscribeAsync(filters, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger?.LogError( + ex, + "MQTT driver '{DriverId}': SUBSCRIBE for {FilterCount} filter(s) failed; the affected tags degrade to Bad.", + _driverId, + filters.Count); + foreach (var filter in filters) + { + DegradeTopic(filter.Topic, ex.GetType().Name); + } + + return 0; + } + + var granted = 0; + foreach (var outcome in outcomes) + { + if (outcome.Granted) + { + _subscribedTopics[outcome.Topic] = 0; + granted++; + } + else + { + _logger?.LogError( + "MQTT driver '{DriverId}': broker refused SUBSCRIBE '{Topic}' ({Reason}); its tags degrade to Bad.", + _driverId, + outcome.Topic, + outcome.Reason); + DegradeTopic(outcome.Topic, outcome.Reason); + } + } + + return granted; + } + + /// Marks every authored reference on as unreachable. + /// The topic filter whose subscription could not be established. + /// The failure reason, for logs. + private void DegradeTopic(string topic, string reason) + { + _subscribedTopics.TryRemove(topic, out _); + + if (!_authored.ByTopic.TryGetValue(topic, out var defs)) + { + return; + } + + var now = DateTime.UtcNow; + var snapshot = new DataValueSnapshot(null, StatusBadCommunicationError, null, now); + foreach (var def in defs) + { + Values.Update(def.Name, snapshot); + Raise(def.Name, snapshot); + } + + _logger?.LogDebug( + "MQTT driver '{DriverId}': degraded {Count} tag(s) on '{Topic}' ({Reason}).", + _driverId, + defs.Length, + topic, + reason); + } + + /// + /// Delivers one message to one tag: honours the retained-seed opt-out, extracts the value, + /// caches it and raises the notification. Never throws. + /// + /// The authored tag this message matched. + /// The message body. + /// The message's retain flag. + /// One clock read shared by the whole fan-out, so siblings agree. + private void Deliver(MqttTagDefinition def, ReadOnlySpan payload, bool retained, DateTime nowUtc) + { + // MQTT 3.1.1 has no retain-handling option, so the broker always replays its retained message + // on subscribe; a tag that opted out of seeding is filtered here rather than at the broker. + if (retained && !def.RetainSeed) + { + return; + } + + DataValueSnapshot snapshot; + try + { + snapshot = Extract(def, payload, nowUtc); + } + catch (Exception ex) + { + // Defence in depth: Extract is written not to throw, but this runs on the library's + // dispatcher and an escape here would stall delivery for every other subscription. + snapshot = new DataValueSnapshot(null, StatusBadDecodingError, null, nowUtc); + _logger?.LogDebug(ex, "MQTT driver '{DriverId}': extraction threw for '{RawPath}'.", _driverId, def.Name); + } + + if (snapshot.StatusCode != StatusGood && _warnedRefs.TryAdd(def.Name, 0)) + { + // Loud once per reference per deploy, quiet thereafter: a mis-authored jsonPath on a 10 Hz + // topic must be diagnosable without drowning the log. + _logger?.LogWarning( + "MQTT driver '{DriverId}': tag '{RawPath}' (topic '{Topic}', {Format}, jsonPath '{JsonPath}', " + + "{DataType}) could not be extracted from its payload; status 0x{Status:X8}.", + _driverId, + def.Name, + def.Topic, + def.PayloadFormat, + def.JsonPath, + def.DataType, + snapshot.StatusCode); + } + + Values.Update(def.Name, snapshot); + Raise(def.Name, snapshot); + } + + /// + /// Raises for a reference, if a live subscription covers it. + /// A throwing subscriber is contained so it cannot starve the rest of the fan-out — or, worse, + /// escape onto MQTTnet's dispatcher thread. + /// + /// The RawPath — the reference the notification is published under. + /// The value/quality/timestamps to publish. + private void Raise(string rawPath, DataValueSnapshot snapshot) + { + if (!_handleByRawPath.TryGetValue(rawPath, out var handle)) + { + // Authored but not subscribed: the value is cached (so a read answers) and nothing is + // published — there is no subscription to attribute it to. + return; + } + + try + { + OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, rawPath, snapshot)); + } + catch (Exception ex) + { + _logger?.LogError( + ex, + "MQTT driver '{DriverId}': a data-change subscriber threw for '{RawPath}'.", + _driverId, + rawPath); + } + } + + /// + /// Extracts the tag's value from a payload according to its . + /// Never throws: every failure becomes a Bad status code on the returned snapshot. + /// + /// The authored tag definition. + /// The message body. + /// Timestamp for the snapshot — MQTT carries no source timestamp of its own. + /// The extracted snapshot. + internal static DataValueSnapshot Extract(MqttTagDefinition def, ReadOnlySpan payload, DateTime nowUtc) + { + switch (def.PayloadFormat) + { + case MqttPayloadFormat.Json: + return ExtractJson(def, payload, nowUtc); + + case MqttPayloadFormat.Raw: + // "Bytes as-is": the payload's UTF-8 text, published verbatim. dataType is deliberately + // NOT applied — Scalar is the format that coerces. Authoring Raw with a numeric + // dataType is a mis-authoring, and one the deploy-time inspection should grow a + // warning for. + return TryDecodeUtf8(payload, out var raw) + ? new DataValueSnapshot(raw, StatusGood, nowUtc, nowUtc) + : Bad(StatusBadDecodingError, nowUtc); + + case MqttPayloadFormat.Scalar: + if (!TryDecodeUtf8(payload, out var text)) + { + return Bad(StatusBadDecodingError, nowUtc); + } + + return TryCoerceText(text, def.DataType, out var scalar) + ? new DataValueSnapshot(scalar, StatusGood, nowUtc, nowUtc) + : Bad(StatusBadTypeMismatch, nowUtc); + + default: + return Bad(StatusBadDecodingError, nowUtc); + } + } + + private static DataValueSnapshot Bad(uint status, DateTime nowUtc) => new(null, status, null, nowUtc); + + /// Parses the payload as JSON, selects the tag's JSONPath, and coerces to its data type. + /// The authored tag definition. + /// The message body. + /// Timestamp for the snapshot. + /// The extracted snapshot. + private static DataValueSnapshot ExtractJson(MqttTagDefinition def, ReadOnlySpan payload, DateTime nowUtc) + { + JsonDocument? doc = null; + try + { + var reader = new Utf8JsonReader(payload); + if (!JsonDocument.TryParseValue(ref reader, out doc)) + { + return Bad(StatusBadDecodingError, nowUtc); + } + + if (!TryEvaluateJsonPath(doc.RootElement, def.JsonPath, out var selected)) + { + return Bad(StatusBadDecodingError, nowUtc); + } + + return TryCoerce(selected, def.DataType, out var value) + ? new DataValueSnapshot(value, StatusGood, nowUtc, nowUtc) + : Bad(StatusBadTypeMismatch, nowUtc); + } + catch (JsonException) + { + return Bad(StatusBadDecodingError, nowUtc); + } + finally + { + doc?.Dispose(); + } + } + + /// + /// Evaluates the supported subset of JSONPath against a document: the root ($), + /// dotted member access ($.a.b), bracketed member access ($['a'] / $["a"]) + /// and non-negative array indexing ($.a[0]). + /// + /// + /// Filters, slices, wildcards and recursive descent ($..x) are deliberately not + /// supported: this repo pins no JSONPath package, adding one for an expression form that + /// selects a set — while a tag needs exactly one scalar — is the wrong trade. An + /// unsupported expression is reported as a miss, so the tag surfaces + /// BadDecodingError plus the one-shot warning rather than a wrong value. + /// + /// The parsed document root. + /// The authored JSONPath. + /// The selected element when this returns . + /// when the path is supported and selects an element. + internal static bool TryEvaluateJsonPath(JsonElement root, string path, out JsonElement value) + { + value = root; + if (string.IsNullOrEmpty(path) || path[0] != '$') + { + return false; + } + + var i = 1; + while (i < path.Length) + { + if (path[i] == '.') + { + i++; + if (i >= path.Length || path[i] == '.') + { + return false; // trailing '.', or recursive descent '..' + } + + var start = i; + while (i < path.Length && path[i] != '.' && path[i] != '[') + { + i++; + } + + var name = path[start..i]; + if (name.Length == 0 || name == "*") + { + return false; + } + + if (value.ValueKind != JsonValueKind.Object || !value.TryGetProperty(name, out var member)) + { + return false; + } + + value = member; + } + else if (path[i] == '[') + { + var close = path.IndexOf(']', i); + if (close < 0) + { + return false; + } + + var inner = path[(i + 1)..close].Trim(); + i = close + 1; + + if (inner.Length >= 2 + && ((inner[0] == '\'' && inner[^1] == '\'') || (inner[0] == '"' && inner[^1] == '"'))) + { + var name = inner[1..^1]; + if (value.ValueKind != JsonValueKind.Object || !value.TryGetProperty(name, out var member)) + { + return false; + } + + value = member; + } + else if (int.TryParse(inner, NumberStyles.Integer, CultureInfo.InvariantCulture, out var index) + && index >= 0) + { + if (value.ValueKind != JsonValueKind.Array || index >= value.GetArrayLength()) + { + return false; + } + + value = value[index]; + } + else + { + return false; // slice, filter or wildcard — outside the supported subset + } + } + else + { + return false; + } + } + + return true; + } + + /// Coerces a selected JSON element to the tag's declared data type. + /// The selected element. + /// The tag's declared type. + /// The coerced value when this returns . + /// when the element fits the declared type. + internal static bool TryCoerce(JsonElement element, DriverDataType type, out object? value) + { + value = null; + + if (element.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + return false; + } + + if (type is DriverDataType.String or DriverDataType.Reference) + { + // A String tag pointed at an object/array gets that subtree's JSON text — the authored + // intent ("give me this fragment") rather than a refusal. + value = element.ValueKind == JsonValueKind.String ? element.GetString() : element.GetRawText(); + return true; + } + + // A JSON string holding a number ({"v":"21.5"}) is common in the wild; parse it as text rather + // than refusing a payload the operator plainly meant. + if (element.ValueKind == JsonValueKind.String) + { + return TryCoerceText(element.GetString() ?? string.Empty, type, out value); + } + + switch (type) + { + case DriverDataType.Boolean: + if (element.ValueKind == JsonValueKind.True) { value = true; return true; } + if (element.ValueKind == JsonValueKind.False) { value = false; return true; } + if (element.ValueKind == JsonValueKind.Number && element.TryGetDouble(out var b)) { value = b != 0d; return true; } + return false; + + case DriverDataType.Int16: + if (element.ValueKind == JsonValueKind.Number && element.TryGetInt16(out var i16)) { value = i16; return true; } + return false; + + case DriverDataType.Int32: + if (element.ValueKind == JsonValueKind.Number && element.TryGetInt32(out var i32)) { value = i32; return true; } + return false; + + case DriverDataType.Int64: + if (element.ValueKind == JsonValueKind.Number && element.TryGetInt64(out var i64)) { value = i64; return true; } + return false; + + case DriverDataType.UInt16: + if (element.ValueKind == JsonValueKind.Number && element.TryGetUInt16(out var u16)) { value = u16; return true; } + return false; + + case DriverDataType.UInt32: + if (element.ValueKind == JsonValueKind.Number && element.TryGetUInt32(out var u32)) { value = u32; return true; } + return false; + + case DriverDataType.UInt64: + if (element.ValueKind == JsonValueKind.Number && element.TryGetUInt64(out var u64)) { value = u64; return true; } + return false; + + case DriverDataType.Float32: + if (element.ValueKind == JsonValueKind.Number && element.TryGetSingle(out var f32)) { value = f32; return true; } + return false; + + case DriverDataType.Float64: + if (element.ValueKind == JsonValueKind.Number && element.TryGetDouble(out var f64)) { value = f64; return true; } + return false; + + case DriverDataType.DateTime: + // Only ISO-8601 text is accepted; a bare number is ambiguous (epoch seconds? ms?) and + // guessing would silently place samples decades apart. + return false; + + default: + return false; + } + } + + /// Parses a textual payload (or JSON string) into the tag's declared data type. + /// The decoded text. + /// The tag's declared type. + /// The parsed value when this returns . + /// when the text parses as the declared type. + internal static bool TryCoerceText(string text, DriverDataType type, out object? value) + { + value = null; + var inv = CultureInfo.InvariantCulture; + var trimmed = text.Trim(); + + switch (type) + { + case DriverDataType.String: + case DriverDataType.Reference: + // Untrimmed: for a String tag the payload IS the value, whitespace included. + value = text; + return true; + + case DriverDataType.Boolean: + if (bool.TryParse(trimmed, out var b)) { value = b; return true; } + if (long.TryParse(trimmed, NumberStyles.Integer, inv, out var bi)) { value = bi != 0L; return true; } + return false; + + case DriverDataType.Int16: + if (short.TryParse(trimmed, NumberStyles.Integer, inv, out var i16)) { value = i16; return true; } + return false; + + case DriverDataType.Int32: + if (int.TryParse(trimmed, NumberStyles.Integer, inv, out var i32)) { value = i32; return true; } + return false; + + case DriverDataType.Int64: + if (long.TryParse(trimmed, NumberStyles.Integer, inv, out var i64)) { value = i64; return true; } + return false; + + case DriverDataType.UInt16: + if (ushort.TryParse(trimmed, NumberStyles.Integer, inv, out var u16)) { value = u16; return true; } + return false; + + case DriverDataType.UInt32: + if (uint.TryParse(trimmed, NumberStyles.Integer, inv, out var u32)) { value = u32; return true; } + return false; + + case DriverDataType.UInt64: + if (ulong.TryParse(trimmed, NumberStyles.Integer, inv, out var u64)) { value = u64; return true; } + return false; + + case DriverDataType.Float32: + if (float.TryParse(trimmed, NumberStyles.Float, inv, out var f32)) { value = f32; return true; } + return false; + + case DriverDataType.Float64: + if (double.TryParse(trimmed, NumberStyles.Float, inv, out var f64)) { value = f64; return true; } + return false; + + case DriverDataType.DateTime: + if (DateTime.TryParse( + trimmed, + inv, + DateTimeStyles.RoundtripKind | DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, + out var dt)) + { + value = dt; + return true; + } + + return false; + + default: + return false; + } + } + + /// + /// Decodes a payload as UTF-8, refusing invalid byte sequences rather than rendering a genuinely + /// binary body as a field of replacement characters (the same choice the browse session makes). + /// + /// The message body. + /// The decoded text when this returns . + /// when the payload is valid UTF-8. + private static bool TryDecodeUtf8(ReadOnlySpan payload, out string text) + { + try + { + text = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true) + .GetString(payload); + return true; + } + catch (DecoderFallbackException) + { + text = string.Empty; + return false; + } + } + + /// + /// Builds the handle's diagnostic id: the ingest mode plus the topics it covers, bounded so a + /// thousand-tag subscription does not produce a thousand-topic string in every log line. + /// + /// The definitions the subscription covers. + /// The diagnostic id. + private string BuildDiagnosticId(IReadOnlyCollection defs) + { + const int MaxNamedTopics = 5; + + var id = Interlocked.Increment(ref _handleSeq); + var topics = defs.Select(d => d.Topic).Distinct(StringComparer.Ordinal).ToList(); + var named = string.Join(',', topics.Take(MaxNamedTopics)); + var overflow = topics.Count > MaxNamedTopics ? $",+{topics.Count - MaxNamedTopics}" : string.Empty; + + return $"mqtt:{_mode}:{id}:[{named}{overflow}]"; + } + + /// Opaque subscription identity; the diagnostic id names the mode and its topics. + /// The human-readable identity surfaced for diagnostics. + private sealed record MqttSubscriptionHandle(string DiagnosticId) : ISubscriptionHandle; + + /// + /// The authored tag table and its derived topic index, published as one immutable snapshot so + /// the dispatcher thread reads a consistent view while a redeploy rebuilds it. + /// + /// RawPath → definition; the v3 resolver's backing table. + /// Concrete topic → every definition bound to it (the fan-out list). + /// Definitions whose authored topic carries an MQTT wildcard. + private sealed record AuthoredTable( + FrozenDictionary ByRawPath, + FrozenDictionary ByTopic, + MqttTagDefinition[] Wildcards) + { + public static readonly AuthoredTable Empty = + Build(new Dictionary(StringComparer.Ordinal)); + + /// Builds the snapshot, splitting concrete topics from wildcard-authored ones. + /// The mapped definitions, keyed by RawPath. + /// The immutable snapshot. + public static AuthoredTable Build(IReadOnlyDictionary byRawPath) + { + var concrete = new Dictionary>(StringComparer.Ordinal); + var wildcards = new List(); + + foreach (var def in byRawPath.Values) + { + if (def.Topic.Contains(MqttTopicFilterComparer.SingleLevelWildcard, StringComparison.Ordinal) + || def.Topic.Contains(MqttTopicFilterComparer.MultiLevelWildcard, StringComparison.Ordinal)) + { + wildcards.Add(def); + continue; + } + + if (!concrete.TryGetValue(def.Topic, out var list)) + { + concrete[def.Topic] = list = []; + } + + list.Add(def); + } + + return new AuthoredTable( + byRawPath.ToFrozenDictionary(StringComparer.Ordinal), + concrete.ToFrozenDictionary(kv => kv.Key, kv => kv.Value.ToArray(), StringComparer.Ordinal), + [.. wildcards]); + } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttSubscriptionManagerTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttSubscriptionManagerTests.cs new file mode 100644 index 00000000..c0f7b3f7 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttSubscriptionManagerTests.cs @@ -0,0 +1,607 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests; + +/// +/// Covers — the Task-6 plain-MQTT ingest path: authored-tag +/// registration keyed by RawPath, topic dedupe + fan-out, payload extraction, the retained seed, +/// and the subscribe/SUBACK contract. Every test runs without a broker: messages are fed straight +/// through (the method MQTTnet's dispatcher +/// calls) and the subscribe leg runs against a fake . +/// +public sealed class MqttSubscriptionManagerTests +{ + // OPC UA status codes, verified against Opc.Ua.StatusCodes rather than recalled. + private const uint Good = 0x00000000u; + private const uint BadDecodingError = 0x80070000u; + private const uint BadTypeMismatch = 0x80740000u; + private const uint BadNodeIdUnknown = 0x80340000u; + private const uint BadCommunicationError = 0x80050000u; + + private const string OvenTempPath = "Plant/Mqtt/broker1/OvenTemp"; + private const string OvenPressPath = "Plant/Mqtt/broker1/OvenPressure"; + private const string OvenTopic = "f/oven/temp"; + + private static RawTagEntry Tag(string rawPath, string tagConfig) => new(rawPath, tagConfig, WriteIdempotent: false); + + private static MqttSubscriptionManager NewManager(IMqttSubscribeTransport? transport = null) => + new(new MqttDriverOptions { Mode = MqttMode.Plain }, "mqtt-1", transport); + + // --------------------------------------------------------------------------------- + // Routing + the RawPath identity of the published reference + // --------------------------------------------------------------------------------- + + /// + /// The plan's first snippet, corrected twice: Double is not a + /// member (Float64 is), and the published reference is + /// asserted to BE the RawPath rather than merely to contain the topic — a topic-keyed + /// publish would satisfy the plan's assertion and be silently dead against + /// DriverHostActor's RawPath-keyed dual-namespace fan-out. + /// + [Fact] + public async Task HandleMessage_MatchingTopic_RaisesDataChangeAtJsonPath_UnderRawPath() + { + var mgr = NewManager(); + mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64"}""")]); + await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + + string? gotRef = null; + object? gotVal = null; + mgr.OnDataChange += (_, e) => { gotRef = e.FullReference; gotVal = e.Snapshot.Value; }; + + mgr.HandleMessage(OvenTopic, """{"value":21.5}"""u8.ToArray(), retained: false); + + gotVal.ShouldBe(21.5); + gotRef.ShouldBe(OvenTempPath); + gotRef.ShouldNotBe(OvenTopic); + } + + /// + /// A chatty broker must never auto-provision a tag the operator did not author. Deliberately + /// run with a live, subscribed tag present rather than an empty table: an empty table makes + /// the assertion unfalsifiable — a "deliver to every authored tag" implementation would pass it + /// for want of anything to deliver to. + /// + [Fact] + public async Task HandleMessage_UnauthoredTopic_RaisesNothing() + { + var mgr = NewManager(); + mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]); + await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + + var fired = false; + mgr.OnDataChange += (_, _) => fired = true; + + mgr.HandleMessage("random/topic", "1"u8.ToArray(), retained: false); + + fired.ShouldBeFalse(); + mgr.Values.Read(OvenTempPath).StatusCode.ShouldNotBe(Good); // and nothing was cached for it either + } + + /// + /// Two tags on ONE topic (different JSONPaths) — the message must reach BOTH. A + /// TryGetValue → first match → return implementation passes every single-tag test and + /// silently starves every tag after the first. + /// + [Fact] + public async Task HandleMessage_TopicSharedByTwoTags_FansOutToEveryMatchingTag() + { + var mgr = NewManager(); + mgr.Register( + [ + Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.temp","dataType":"Float64"}"""), + Tag(OvenPressPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.press","dataType":"Float64"}"""), + ]); + await mgr.SubscribeAsync([OvenTempPath, OvenPressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + + var seen = new Dictionary(); + mgr.OnDataChange += (_, e) => seen[e.FullReference] = e.Snapshot.Value; + + mgr.HandleMessage(OvenTopic, """{"temp":21.5,"press":1.2}"""u8.ToArray(), retained: false); + + seen.Count.ShouldBe(2); + seen[OvenTempPath].ShouldBe(21.5); + seen[OvenPressPath].ShouldBe(1.2); + } + + /// + /// An authored tag nobody subscribed to still caches its value (so a later + /// IReadable.ReadAsync answers) but must not raise OnDataChange — there is no + /// subscription handle to attribute it to. + /// + [Fact] + public void HandleMessage_AuthoredButUnsubscribedTag_CachesValue_RaisesNothing() + { + var mgr = NewManager(); + mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]); + + var fired = false; + mgr.OnDataChange += (_, _) => fired = true; + + mgr.HandleMessage(OvenTopic, "7.5"u8.ToArray(), retained: false); + + fired.ShouldBeFalse(); + mgr.Values.Read(OvenTempPath).Value.ShouldBe(7.5); + } + + /// After UnsubscribeAsync the reference stops publishing. + [Fact] + public async Task UnsubscribeAsync_StopsRaisingForThoseReferences() + { + var mgr = NewManager(); + mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]); + var handle = await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + + var count = 0; + mgr.OnDataChange += (_, _) => count++; + mgr.HandleMessage(OvenTopic, "1"u8.ToArray(), retained: false); + + await mgr.UnsubscribeAsync(handle, TestContext.Current.CancellationToken); + mgr.HandleMessage(OvenTopic, "2"u8.ToArray(), retained: false); + + count.ShouldBe(1); + } + + // --------------------------------------------------------------------------------- + // Wildcard-authored topics (accepted by the parser, warned at deploy) + // --------------------------------------------------------------------------------- + + [Theory] + [InlineData("f/+/temp", "f/oven/temp", true)] + [InlineData("f/+/temp", "f/oven/deep/temp", false)] // '+' is single-level + [InlineData("f/#", "f/oven/deep/temp", true)] // '#' is multi-level + [InlineData("f/#", "g/oven/temp", false)] + public async Task HandleMessage_WildcardAuthoredTopic_UsesRealMqttFilterSemantics( + string authoredTopic, string incomingTopic, bool expectMatch) + { + var mgr = NewManager(); + mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{authoredTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]); + await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + + var fired = false; + mgr.OnDataChange += (_, _) => fired = true; + + mgr.HandleMessage(incomingTopic, "1"u8.ToArray(), retained: false); + + fired.ShouldBe(expectMatch); + } + + // --------------------------------------------------------------------------------- + // Retained seed + // --------------------------------------------------------------------------------- + + [Fact] + public async Task HandleMessage_RetainedMessage_SeedsWhenRetainSeedIsTrue() + { + var mgr = NewManager(); + // retainSeed absent ⇒ true (the factory default). + mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]); + await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + + object? gotVal = null; + mgr.OnDataChange += (_, e) => gotVal = e.Snapshot.Value; + + mgr.HandleMessage(OvenTopic, "3.5"u8.ToArray(), retained: true); + + gotVal.ShouldBe(3.5); + } + + [Fact] + public async Task HandleMessage_RetainedMessage_IgnoredWhenRetainSeedIsFalse() + { + var mgr = NewManager(); + mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64","retainSeed":false}""")]); + await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + + var fired = false; + mgr.OnDataChange += (_, _) => fired = true; + + mgr.HandleMessage(OvenTopic, "3.5"u8.ToArray(), retained: true); + fired.ShouldBeFalse(); + + // A live (non-retained) publish on the same tag still lands — retainSeed gates the seed only. + mgr.HandleMessage(OvenTopic, "3.5"u8.ToArray(), retained: false); + fired.ShouldBeTrue(); + } + + // --------------------------------------------------------------------------------- + // Value extraction — Json / Scalar / Raw, and per-tag degradation + // --------------------------------------------------------------------------------- + + [Theory] + [InlineData("$", """21.5""", 21.5)] + [InlineData("$.value", """{"value":21.5}""", 21.5)] + [InlineData("$.a.b", """{"a":{"b":21.5}}""", 21.5)] + [InlineData("$.arr[1]", """{"arr":[1.5,21.5]}""", 21.5)] + [InlineData("$['value']", """{"value":21.5}""", 21.5)] + public async Task HandleMessage_Json_ExtractsSupportedJsonPathShapes(string jsonPath, string payload, double expected) + { + var mgr = NewManager(); + mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"{{jsonPath.Replace("\\", "\\\\").Replace("\"", "\\\"")}}","dataType":"Float64"}""")]); + await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + + DataValueSnapshot? snap = null; + mgr.OnDataChange += (_, e) => snap = e.Snapshot; + + mgr.HandleMessage(OvenTopic, System.Text.Encoding.UTF8.GetBytes(payload), retained: false); + + snap!.StatusCode.ShouldBe(Good); + snap.Value.ShouldBe(expected); + } + + [Fact] + public async Task HandleMessage_MalformedJsonPayload_PublishesBadDecodingError_NeverThrows() + { + var mgr = NewManager(); + mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64"}""")]); + await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + + DataValueSnapshot? snap = null; + mgr.OnDataChange += (_, e) => snap = e.Snapshot; + + Should.NotThrow(() => mgr.HandleMessage(OvenTopic, "{not json"u8.ToArray(), retained: false)); + + snap!.StatusCode.ShouldBe(BadDecodingError); + snap.Value.ShouldBeNull(); + } + + [Theory] + [InlineData("$.missing")] + [InlineData("$..value")] // recursive descent — outside the supported subset + [InlineData("$.arr[9]")] + public async Task HandleMessage_UnresolvableOrUnsupportedJsonPath_PublishesBadDecodingError(string jsonPath) + { + var mgr = NewManager(); + mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"{{jsonPath}}","dataType":"Float64"}""")]); + await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + + DataValueSnapshot? snap = null; + mgr.OnDataChange += (_, e) => snap = e.Snapshot; + + mgr.HandleMessage(OvenTopic, """{"value":21.5,"arr":[1]}"""u8.ToArray(), retained: false); + + snap!.StatusCode.ShouldBe(BadDecodingError); + } + + [Fact] + public async Task HandleMessage_ValueNotCoercibleToDeclaredDataType_PublishesBadTypeMismatch() + { + var mgr = NewManager(); + mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64"}""")]); + await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + + DataValueSnapshot? snap = null; + mgr.OnDataChange += (_, e) => snap = e.Snapshot; + + mgr.HandleMessage(OvenTopic, """{"value":"not-a-number"}"""u8.ToArray(), retained: false); + + snap!.StatusCode.ShouldBe(BadTypeMismatch); + snap.Value.ShouldBeNull(); + } + + [Theory] + [InlineData("Boolean", "true", true)] + [InlineData("Int32", "42", 42)] + [InlineData("Int64", "42", 42L)] + [InlineData("Float32", "1.5", 1.5f)] + [InlineData("Float64", "1.5", 1.5)] + [InlineData("String", "hello", "hello")] + public async Task HandleMessage_Scalar_ParsesByDeclaredDataType(string dataType, string payload, object expected) + { + var mgr = NewManager(); + mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"{{dataType}}"}""")]); + await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + + DataValueSnapshot? snap = null; + mgr.OnDataChange += (_, e) => snap = e.Snapshot; + + mgr.HandleMessage(OvenTopic, System.Text.Encoding.UTF8.GetBytes(payload), retained: false); + + snap!.StatusCode.ShouldBe(Good); + snap.Value.ShouldBe(expected); + } + + /// + /// Raw means "bytes as-is": the payload is published as its UTF-8 text and the declared + /// dataType is deliberately NOT applied (Scalar is the member that coerces). + /// + [Fact] + public async Task HandleMessage_Raw_PublishesUtf8TextAndIgnoresDeclaredDataType() + { + var mgr = NewManager(); + mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Raw","dataType":"Float64"}""")]); + await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + + DataValueSnapshot? snap = null; + mgr.OnDataChange += (_, e) => snap = e.Snapshot; + + mgr.HandleMessage(OvenTopic, "21.5"u8.ToArray(), retained: false); + + snap!.StatusCode.ShouldBe(Good); + snap.Value.ShouldBe("21.5"); + } + + [Fact] + public async Task HandleMessage_RawNonUtf8Payload_PublishesBadDecodingError_NeverThrows() + { + var mgr = NewManager(); + mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Raw","dataType":"String"}""")]); + await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + + DataValueSnapshot? snap = null; + mgr.OnDataChange += (_, e) => snap = e.Snapshot; + + Should.NotThrow(() => mgr.HandleMessage(OvenTopic, new byte[] { 0xC3, 0x28 }, retained: false)); + + snap!.StatusCode.ShouldBe(BadDecodingError); + } + + /// + /// HandleMessage runs on MQTTnet's dispatcher thread, so nothing it calls — including a + /// misbehaving downstream subscriber — may escape and stall the client's pump. + /// + [Fact] + public async Task HandleMessage_ThrowingSubscriber_DoesNotEscapeToTheDispatcher() + { + var mgr = NewManager(); + mgr.Register( + [ + Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""), + Tag(OvenPressPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""), + ]); + await mgr.SubscribeAsync([OvenTempPath, OvenPressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + + var delivered = new List(); + mgr.OnDataChange += (_, e) => + { + delivered.Add(e.FullReference); + throw new InvalidOperationException("downstream blew up"); + }; + + Should.NotThrow(() => mgr.HandleMessage(OvenTopic, "1"u8.ToArray(), retained: false)); + + // And a throwing subscriber must not starve the tags behind it in the fan-out. + delivered.Count.ShouldBe(2); + } + + // --------------------------------------------------------------------------------- + // Registration + // --------------------------------------------------------------------------------- + + [Fact] + public void Register_UnmappableTagConfig_IsSkipped_NeverThrows() + { + var mgr = NewManager(); + + var mapped = 0; + Should.NotThrow(() => mapped = mgr.Register( + [ + Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""), + Tag(OvenPressPath, """{"payloadFormat":"Scalar"}"""), // no topic ⇒ rejected by the factory + Tag("Plant/Mqtt/broker1/Junk", "not json at all"), + ])); + + mapped.ShouldBe(1); + mgr.TryResolve(OvenTempPath, out _).ShouldBeTrue(); + mgr.TryResolve(OvenPressPath, out _).ShouldBeFalse(); + } + + /// Re-registering replaces the authored table wholesale — a redeploy is not additive. + [Fact] + public void Register_Twice_ReplacesTheAuthoredTable() + { + var mgr = NewManager(); + mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]); + + mgr.Register([Tag(OvenPressPath, """{"topic":"f/oven/press","payloadFormat":"Scalar","dataType":"Float64"}""")]); + + mgr.TryResolve(OvenTempPath, out _).ShouldBeFalse(); + mgr.TryResolve(OvenPressPath, out _).ShouldBeTrue(); + } + + // --------------------------------------------------------------------------------- + // Filter building — one SUBSCRIBE per distinct topic, strongest QoS, retain handling + // --------------------------------------------------------------------------------- + + [Fact] + public void BuildFilters_DedupesTopics_TakesStrongestQos_AndSeedsWhenAnyTagWants() + { + var a = new MqttTagDefinition("p/a", OvenTopic, MqttPayloadFormat.Scalar, "$", DriverDataType.Float64, Qos: 0, RetainSeed: false); + var b = new MqttTagDefinition("p/b", OvenTopic, MqttPayloadFormat.Scalar, "$", DriverDataType.Float64, Qos: 2, RetainSeed: true); + var c = new MqttTagDefinition("p/c", "f/oven/press", MqttPayloadFormat.Scalar, "$", DriverDataType.Float64, Qos: null, RetainSeed: false); + + var filters = MqttSubscriptionManager.BuildFilters([a, b, c], defaultQos: 1); + + filters.Count.ShouldBe(2); + var oven = filters.Single(f => f.Topic == OvenTopic); + oven.Qos.ShouldBe(2); // strongest wins — nobody gets a weaker guarantee than authored + oven.SeedRetained.ShouldBeTrue(); + var press = filters.Single(f => f.Topic == "f/oven/press"); + press.Qos.ShouldBe(1); // null ⇒ the driver-level default + press.SeedRetained.ShouldBeFalse(); + } + + // --------------------------------------------------------------------------------- + // Subscribe / SUBACK contract + // --------------------------------------------------------------------------------- + + [Fact] + public async Task SubscribeAsync_IssuesTheFirstSubscribeItself_OneFilterPerDistinctTopic() + { + var transport = new FakeTransport(); + var mgr = NewManager(transport); + mgr.Register( + [ + Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""), + Tag(OvenPressPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""), + ]); + + await mgr.SubscribeAsync([OvenTempPath, OvenPressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + + transport.Calls.Count.ShouldBe(1); + transport.Calls[0].Select(f => f.Topic).ShouldBe([OvenTopic]); + } + + [Fact] + public async Task SubscribeAsync_HandleDiagnosticId_NamesModeAndFilters() + { + var mgr = NewManager(); + mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]); + + var handle = await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + + handle.DiagnosticId.ShouldContain("Plain"); + handle.DiagnosticId.ShouldContain(OvenTopic); + } + + /// An already-subscribed topic is not re-SUBSCRIBEd by a second overlapping subscription. + [Fact] + public async Task SubscribeAsync_AlreadySubscribedTopic_IsNotResubscribed() + { + var transport = new FakeTransport(); + var mgr = NewManager(transport); + mgr.Register( + [ + Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""), + Tag(OvenPressPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""), + ]); + + await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + await mgr.SubscribeAsync([OvenPressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + + transport.Calls.Count.ShouldBe(1); + } + + [Fact] + public async Task SubscribeAsync_UnknownReference_MarksItBadNodeIdUnknown_AndDoesNotThrow() + { + var mgr = NewManager(); + mgr.Register([]); + + await Should.NotThrowAsync(() => + mgr.SubscribeAsync(["Plant/Mqtt/broker1/Nope"], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken)); + + mgr.Values.Read("Plant/Mqtt/broker1/Nope").StatusCode.ShouldBe(BadNodeIdUnknown); + } + + /// + /// A SUBACK failure on the ISubscribable path degrades the affected references to Bad + /// and returns a handle — it must never hang, and must never throw out of the OPC UA server's + /// subscribe call. + /// + [Fact] + public async Task SubscribeAsync_SubackFailure_DegradesRefsToBad_ReturnsHandle_DoesNotThrow() + { + var transport = new FakeTransport { Reject = _ => true }; + var mgr = NewManager(transport); + mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]); + + // Not wrapped in Should.NotThrowAsync: an escaping exception fails this test on its own, and + // the handle must still come back. + var handle = await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + + handle.ShouldNotBeNull(); + mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(BadCommunicationError); + } + + [Fact] + public async Task SubscribeAsync_TransportThrows_DegradesRefsToBad_DoesNotThrow() + { + var transport = new FakeTransport { Throw = new InvalidOperationException("broker gone") }; + var mgr = NewManager(transport); + mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]); + + await Should.NotThrowAsync(() => + mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken)); + + mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(BadCommunicationError); + } + + // --------------------------------------------------------------------------------- + // Reconnect re-subscribe + // --------------------------------------------------------------------------------- + + [Fact] + public async Task OnReconnectedAsync_ReSubscribesEveryLiveTopic_Idempotently() + { + var transport = new FakeTransport(); + var mgr = NewManager(transport); + mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]); + await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + + await mgr.OnReconnectedAsync(TestContext.Current.CancellationToken); + await mgr.OnReconnectedAsync(TestContext.Current.CancellationToken); + + transport.Calls.Count.ShouldBe(3); // initial + two reconnects + transport.Calls[1].Select(f => f.Topic).ShouldBe([OvenTopic]); + transport.Calls[2].Select(f => f.Topic).ShouldBe([OvenTopic]); + } + + /// + /// A total re-subscribe failure THROWS: 's documented contract is + /// that a throwing Reconnected subscriber tears the session down and retries under + /// backoff, and a connected-but-deaf client is the one outcome this driver must never serve. + /// + [Fact] + public async Task OnReconnectedAsync_TotalFailure_Throws_SoTheSessionIsTornDownAndRetried() + { + var transport = new FakeTransport(); + var mgr = NewManager(transport); + mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]); + await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + + transport.Throw = new InvalidOperationException("broker gone"); + + await Should.ThrowAsync(() => mgr.OnReconnectedAsync(TestContext.Current.CancellationToken)); + } + + /// + /// A PARTIAL rejection (e.g. one ACL-denied topic) must NOT throw — tearing the session down + /// forever because one of many topics is denied would take the whole driver dark. Only the + /// denied references degrade. + /// + [Fact] + public async Task OnReconnectedAsync_PartialRejection_DoesNotThrow_OnlyDeniedRefsDegrade() + { + var transport = new FakeTransport(); + var mgr = NewManager(transport); + mgr.Register( + [ + Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""), + Tag(OvenPressPath, """{"topic":"f/oven/press","payloadFormat":"Scalar","dataType":"Float64"}"""), + ]); + await mgr.SubscribeAsync([OvenTempPath, OvenPressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + mgr.HandleMessage(OvenTopic, "1"u8.ToArray(), retained: false); + + transport.Reject = t => t == "f/oven/press"; + + await Should.NotThrowAsync(() => mgr.OnReconnectedAsync(TestContext.Current.CancellationToken)); + + mgr.Values.Read(OvenPressPath).StatusCode.ShouldBe(BadCommunicationError); + mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(Good); + } + + /// Records every subscribe call so dedupe / re-subscribe can be asserted without a broker. + private sealed class FakeTransport : IMqttSubscribeTransport + { + public List> Calls { get; } = []; + + /// Topics for which the fake SUBACK reports a rejection. + public Func Reject { get; set; } = _ => false; + + /// When set, the whole subscribe call throws instead of answering. + public Exception? Throw { get; set; } + + public Task> SubscribeAsync( + IReadOnlyList filters, CancellationToken cancellationToken) + { + Calls.Add(filters); + if (Throw is not null) throw Throw; + IReadOnlyList outcomes = + [.. filters.Select(f => new MqttSubscribeOutcome(f.Topic, !Reject(f.Topic), Reject(f.Topic) ? "NotAuthorized" : "GrantedQoS1"))]; + return Task.FromResult(outcomes); + } + } +} From 4ec01bdfc15997d888eae08b2a8b7bc561ae58fe Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 16:30:45 -0400 Subject: [PATCH 14/43] fix(mqtt): bound browse observation against a chatty or malicious broker Three review findings, all "unbounded work/memory from untrusted broker input" rather than correctness bugs - but this points at a live plant broker. 1. InferPayload decoded the WHOLE payload on MQTTnet's dispatcher thread before trimming to 64 chars. The node cap never engages here: a multi-MB blob on an already-known topic creates no node, it just updates one, so the cost repeats per message forever. Now at most PayloadInspectMaxBytes (512) are examined. A blind slice can cut a multi-byte sequence, which the strict decoder would report as "binary" - TrimToUtf8Boundary backs the window off to the last whole sequence so good UTF-8 is never mislabelled. A clipped payload is String by construction rather than inferred from a partial view. 2. The node cap bounded COUNT, not bytes. MQTT topics run to ~65KB, so 50k nodes near that ceiling is a multi-GB tree. Topics now bounded at 1024 chars and segments at 256, rejected whole (a truncated topic is a DIFFERENT topic the picker would commit as a tag address) and surfaced via an __oversized__ marker kept distinct from __truncated__ - the operator's remedy differs. 3. Control characters in a snippet would break the picker's single-line row; Trim() only strips the ends. Now scrubbed to spaces. Each guard was falsified independently by neutering it and confirming exactly one test reddens. That found a real gap: the oversized-topic test was passing via the SEGMENT bound, leaving the whole-topic bound untested - the test now uses many short segments so only the topic bound can reject it. Also records the .Driver project reference as a known, deliberate exception to the .Browser -> .Contracts pattern, with its cost (AdminUI gains Core + Polly + Serilog) and the clean fix (a leaf transport project; NOT a move into .Contracts, which is deliberately transport-free). Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../MqttBrowseSession.cs | 168 ++++++++++++++++-- .../MqttDriverBrowser.cs | 8 + ....MOM.WW.OtOpcUa.Driver.Mqtt.Browser.csproj | 32 +++- .../MqttBrowseSessionTests.cs | 116 ++++++++++++ 4 files changed, 302 insertions(+), 22 deletions(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs index eca93d61..3a393310 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs @@ -41,9 +41,41 @@ internal sealed class MqttBrowseSession : IBrowseSession /// Maximum characters of a payload retained as the picker's confirm-it's-the-right-topic snippet. internal const int PayloadSnippetMaxChars = 64; + /// + /// Hard cap on the payload bytes fed to the UTF-8 decode. A # subscription receives + /// whatever the broker has — multi-MB retained blobs and JSON dumps included — and the node + /// cap does not help: a large payload on an already-known topic creates no node, it + /// just updates one, so an uncapped decode is unbounded work on MQTTnet's pump thread on + /// every single message, forever. 512 is × 4 (UTF-8's + /// worst-case bytes per char, so the snippet can always be filled) doubled, leaving headroom + /// for leading whitespace ahead of a scalar. + /// + internal const int PayloadInspectMaxBytes = 512; + + /// + /// Cap on a whole topic name. MQTT permits ~65 KB, and the node cap bounds the node + /// count only — 50 000 nodes near that ceiling is a multi-GB tree. Topics past this + /// are rejected outright rather than truncated: a truncated topic is a different + /// topic, and the picker would commit it as a tag address. + /// + internal const int MaxTopicChars = 1024; + + /// Cap on one topic segment (one tree node's label + display name). + internal const int MaxSegmentChars = 256; + /// Sentinel node id of the "results truncated" marker, mirroring CapturedTreeBrowseSession. internal const string TruncatedNodeId = "__truncated__"; + /// Sentinel node id of the "oversized topics rejected" marker. + internal const string OversizedNodeId = "__oversized__"; + + /// + /// Throw-on-invalid so genuinely binary payloads are reported as such rather than silently + /// rendered as a field of replacement characters. Cached: allocating an encoding per message + /// would be per-message garbage on the dispatcher thread. + /// + private static readonly UTF8Encoding Utf8Strict = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + /// Bound on the courtesy DISCONNECT issued at dispose — teardown never hangs on a dead broker. private static readonly TimeSpan DisconnectTimeout = TimeSpan.FromSeconds(5); @@ -60,6 +92,7 @@ internal sealed class MqttBrowseSession : IBrowseSession private int _disposed; private long _lastUsedTicksUtc = DateTime.UtcNow.Ticks; private int _nodeCount; + private int _oversized; private int _publishCount; private int _truncated; @@ -95,6 +128,14 @@ internal sealed class MqttBrowseSession : IBrowseSession /// True once the node cap stopped recording; surfaced as a marker node from . internal bool Truncated => Volatile.Read(ref _truncated) != 0; + /// + /// True once a topic was rejected for breaching / + /// ; surfaced as its own marker node from + /// . Kept distinct from because the operator's + /// remedy differs — narrow the prefix, versus this broker publishes absurd topic names. + /// + internal bool OversizedRejected => Volatile.Read(ref _oversized) != 0; + private bool Disposed => Volatile.Read(ref _disposed) != 0; /// @@ -123,6 +164,15 @@ internal sealed class MqttBrowseSession : IBrowseSession HasChildrenHint: false)); } + if (OversizedRejected) + { + nodes.Add(new BrowseNode( + OversizedNodeId, + "⚠ Some topics were too long to show — use manual entry for those", + BrowseNodeKind.Folder, + HasChildrenHint: false)); + } + Touch(); return Task.FromResult>(nodes); } @@ -209,7 +259,24 @@ internal sealed class MqttBrowseSession : IBrowseSession { if (Disposed || string.IsNullOrWhiteSpace(topic)) return; + if (topic.Length > MaxTopicChars) + { + Volatile.Write(ref _oversized, 1); + return; + } + var segments = topic.Split('/'); + + // Checked as a pre-pass so a rejected topic leaves no half-built path behind. + foreach (var segment in segments) + { + if (segment.Length > MaxSegmentChars) + { + Volatile.Write(ref _oversized, 1); + return; + } + } + var level = _roots; TopicNode? node = null; var path = string.Empty; @@ -270,6 +337,10 @@ internal sealed class MqttBrowseSession : IBrowseSession /// /// Best-effort type inference from a payload body. Explicit authoring stays the authority /// (design §4) — this only decorates the picker. Non-UTF-8 bodies are reported as opaque. + /// + /// Runs on MQTTnet's dispatcher thread, so the work is bounded before any decode: at most + /// bytes are ever examined, however large the message. + /// /// /// The raw message body. /// The inferred type and a bounded display snippet. @@ -277,37 +348,102 @@ internal sealed class MqttBrowseSession : IBrowseSession { if (payload.IsEmpty) return new ObservedValue(DriverDataType.String, "(empty)"); + var totalBytes = payload.Length; + var clipped = totalBytes > PayloadInspectMaxBytes; + // A blind slice can cut a multi-byte sequence in half, which the strict decoder would report + // as "binary" — mislabelling a perfectly good UTF-8 payload. Back the window off to the last + // whole sequence instead. + var window = clipped ? TrimToUtf8Boundary(payload[..PayloadInspectMaxBytes]) : payload; + string text; try { - // Throw-on-invalid so genuinely binary payloads are reported as such rather than - // silently rendered as a field of replacement characters. - text = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true) - .GetString(payload); + text = Utf8Strict.GetString(window); } catch (DecoderFallbackException) { - return new ObservedValue(DriverDataType.String, $"({payload.Length} bytes, binary)"); + return new ObservedValue(DriverDataType.String, $"({totalBytes} bytes, binary)"); } var trimmed = text.Trim(); - var snippet = trimmed.Length > PayloadSnippetMaxChars - ? string.Concat(trimmed.AsSpan(0, PayloadSnippetMaxChars), "…") - : trimmed; // Ordered narrowest-first. "1" infers Int64 rather than Boolean: an integer reading is the // commoner meaning and, unlike the reverse, does not silently collapse a range to two states. - var dataType = trimmed switch + // A clipped payload is never a scalar, and inferring from a partial view could read a + // whitespace-padded prefix as a number it is not — so it is String by construction. + var dataType = clipped + ? DriverDataType.String + : trimmed switch + { + _ when bool.TryParse(trimmed, out _) => DriverDataType.Boolean, + _ when long.TryParse(trimmed, NumberStyles.Integer, CultureInfo.InvariantCulture, out _) + => DriverDataType.Int64, + _ when double.TryParse(trimmed, NumberStyles.Float, CultureInfo.InvariantCulture, out _) + => DriverDataType.Float64, + _ => DriverDataType.String, + }; + + return new ObservedValue(dataType, Sanitize(trimmed, clipped)); + } + + /// + /// Renders decoded payload text as a single-line, length-bounded snippet. Control characters + /// (newlines, tabs, ANSI escapes) become spaces — only strips them + /// at the ends, and an embedded one would break the picker's single-line attribute row. + /// + /// The trimmed decoded text. + /// Whether bytes beyond the inspection window were discarded. + /// The display snippet. + private static string Sanitize(string text, bool clipped) + { + var overLength = text.Length > PayloadSnippetMaxChars; + var span = overLength ? text.AsSpan(0, PayloadSnippetMaxChars) : text.AsSpan(); + + // clipped, not just overLength: a huge payload whose first 512 bytes are mostly whitespace + // yields a short snippet that would otherwise claim to be the whole message. + return overLength || clipped ? Scrub(span) + "…" : Scrub(span); + } + + /// Replaces control characters with spaces and trims the trailing edge. + /// The characters to scrub. + /// The scrubbed text. + private static string Scrub(ReadOnlySpan span) + { + Span buffer = span.Length <= 128 ? stackalloc char[span.Length] : new char[span.Length]; + for (var i = 0; i < span.Length; i++) buffer[i] = char.IsControl(span[i]) ? ' ' : span[i]; + return new string(buffer).TrimEnd(); + } + + /// + /// Shortens a byte window so it ends on a whole UTF-8 sequence. Walks back over continuation + /// bytes (10xxxxxx) to the lead byte and drops it when the sequence it starts runs past + /// the window. A stray continuation byte is left in place — that is genuine corruption, and the + /// strict decoder should say so. + /// + /// The blindly-sliced window. + /// The window, shortened to a sequence boundary. + private static ReadOnlySpan TrimToUtf8Boundary(ReadOnlySpan bytes) + { + var i = bytes.Length - 1; + var walked = 0; + while (i >= 0 && (bytes[i] & 0xC0) == 0x80 && walked < 3) { - _ when bool.TryParse(trimmed, out _) => DriverDataType.Boolean, - _ when long.TryParse(trimmed, NumberStyles.Integer, CultureInfo.InvariantCulture, out _) - => DriverDataType.Int64, - _ when double.TryParse(trimmed, NumberStyles.Float, CultureInfo.InvariantCulture, out _) - => DriverDataType.Float64, - _ => DriverDataType.String, + i--; + walked++; + } + + if (i < 0) return ReadOnlySpan.Empty; // nothing but continuation bytes + + var needed = bytes[i] switch + { + < 0x80 => 1, // ASCII + >= 0xF0 => 4, + >= 0xE0 => 3, + >= 0xC0 => 2, + _ => 1, // stray continuation byte — let the decoder reject it }; - return new ObservedValue(dataType, snippet); + return bytes.Length - i >= needed ? bytes : bytes[..i]; } /// Projects one level of the tree into browse nodes, ordered so the picker is stable. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs index ee87b9b4..ce2f3366 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs @@ -19,6 +19,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser; /// publishes: an operator opening a picker must not be able to inject a message into a /// running plant. /// +/// +/// Layering note. Unlike every other bespoke browser here, this project references the +/// runtime .Driver project (to reuse MqttConnection.BuildClientOptions rather +/// than fork the TLS / CA-pin path). That is a known, deliberate exception with a documented +/// cost and a documented clean fix — see the comment on that ProjectReference in +/// ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser.csproj before copying this project as a +/// template. +/// /// public sealed class MqttDriverBrowser : IDriverBrowser { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser.csproj index bdc8e8c6..e32fa07f 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser.csproj @@ -14,12 +14,32 @@ diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowseSessionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowseSessionTests.cs index b2a309c9..bff929cc 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowseSessionTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowseSessionTests.cs @@ -248,6 +248,122 @@ public sealed class MqttBrowseSessionTests root.ShouldContain(n => n.NodeId == MqttBrowseSession.TruncatedNodeId); } + [Fact] + public async Task OversizedTopic_IsRejectedWhole_AndSurfacesAMarker() + { + // A truncated topic is a DIFFERENT topic — the picker would commit it as a tag address. + // Every segment here is 4 chars, well under MaxSegmentChars, so ONLY the whole-topic bound + // can reject it — the segment guard must not be what makes this test pass. + await using var s = new MqttBrowseSession(MqttMode.Plain); + s.ObserveTopicForTest("good/topic", "1"); + + var manyShortSegments = "huge/" + string.Join('/', Enumerable.Repeat("abc", MqttBrowseSession.MaxTopicChars / 4 + 8)); + manyShortSegments.Length.ShouldBeGreaterThan(MqttBrowseSession.MaxTopicChars); + manyShortSegments.Split('/').ShouldAllBe(seg => seg.Length <= MqttBrowseSession.MaxSegmentChars); + s.ObserveTopicForTest(manyShortSegments, "1"); + + var root = await s.RootAsync(TestContext.Current.CancellationToken); + root.ShouldContain(n => n.NodeId == MqttBrowseSession.OversizedNodeId); + root.ShouldContain(n => n.DisplayName == "good"); + root.ShouldNotContain(n => n.DisplayName == "huge"); // no half-built path left behind + } + + [Fact] + public async Task OversizedSegment_IsRejectedWhole_EvenWhenTheTopicFits() + { + await using var s = new MqttBrowseSession(MqttMode.Plain); + s.ObserveTopicForTest("a/" + new string('y', MqttBrowseSession.MaxSegmentChars + 1) + "/b", "1"); + + s.OversizedRejected.ShouldBeTrue(); + (await s.ExpandAsync("a", TestContext.Current.CancellationToken)).ShouldBeEmpty(); + var root = await s.RootAsync(TestContext.Current.CancellationToken); + root.ShouldNotContain(n => n.DisplayName == "a"); + } + + [Fact] + public async Task TopicAtExactlyTheCap_IsAccepted() + { + await using var s = new MqttBrowseSession(MqttMode.Plain); + s.ObserveTopicForTest(new string('z', MqttBrowseSession.MaxSegmentChars), "1"); + + s.OversizedRejected.ShouldBeFalse(); + (await s.RootAsync(TestContext.Current.CancellationToken)).ShouldHaveSingleItem(); + } + + // ---------------------------------------------------------------- payload bounds + + [Fact] + public async Task PayloadBeyondTheInspectionWindow_IsNeverExamined() + { + // The discriminating test for the byte cap: content past PayloadInspectMaxBytes cannot + // influence the result at all. With an unbounded decode the trailing scalar would be found + // and typed Int64, and would show up in the snippet. + await using var s = new MqttBrowseSession(MqttMode.Plain); + s.ObserveTopicForTest( + "t/tail", + new string(' ', MqttBrowseSession.PayloadInspectMaxBytes + 64) + "42"); + + var a = (await s.AttributesAsync("t/tail", TestContext.Current.CancellationToken)).ShouldHaveSingleItem(); + a.DriverDataType.ShouldBe(nameof(Core.Abstractions.DriverDataType.String)); + a.SecurityClass.ShouldNotContain("42"); + } + + [Fact] + public async Task HugePayload_YieldsABoundedSnippetMarkedAsClipped() + { + // A '#' subscription receives multi-MB retained blobs; the node cap never engages for a + // large payload on an already-known topic, so the decode itself must be bounded. + await using var s = new MqttBrowseSession(MqttMode.Plain); + s.ObserveTopicForTest("t/blob", new string('a', 5_000_000)); + + var a = (await s.AttributesAsync("t/blob", TestContext.Current.CancellationToken)).ShouldHaveSingleItem(); + a.SecurityClass.Length.ShouldBeLessThanOrEqualTo(MqttBrowseSession.PayloadSnippetMaxChars + 1); + a.SecurityClass.ShouldEndWith("…"); + a.DriverDataType.ShouldBe(nameof(Core.Abstractions.DriverDataType.String)); + } + + [Fact] + public async Task ClippedMultiByteUtf8_IsNotMislabelledAsBinary() + { + // The inspection window can land mid-sequence; backing off to a whole sequence keeps a + // perfectly good UTF-8 payload from being reported as binary. + await using var s = new MqttBrowseSession(MqttMode.Plain); + + // 3 bytes per char, so 512 / 3 leaves a partial sequence at the window edge. + s.ObserveTopicForTest("t/utf8", new string('☃', 4000)); + + var a = (await s.AttributesAsync("t/utf8", TestContext.Current.CancellationToken)).ShouldHaveSingleItem(); + a.SecurityClass.ShouldStartWith("☃"); + a.SecurityClass.ShouldNotContain("binary"); + a.SecurityClass.ShouldNotContain("�"); // no replacement char from a split sequence + } + + [Fact] + public async Task BinaryPayload_IsReportedAsOpaque_WithItsTrueLength() + { + await using var s = new MqttBrowseSession(MqttMode.Plain); + var binary = new byte[2048]; + Array.Fill(binary, (byte)0xC0); // never legal UTF-8 + s.Observe("t/bin", binary); + + var a = (await s.AttributesAsync("t/bin", TestContext.Current.CancellationToken)).ShouldHaveSingleItem(); + a.SecurityClass.ShouldBe("(2048 bytes, binary)"); // full length, not the clipped window + } + + [Fact] + public async Task EmbeddedControlCharacters_AreScrubbedToSpaces() + { + // Trim() only strips the ends; an embedded newline would break the picker's single-line row. + await using var s = new MqttBrowseSession(MqttMode.Plain); + s.ObserveTopicForTest("t/multiline", "line one\nline two\ttabbed"); + + var a = (await s.AttributesAsync("t/multiline", TestContext.Current.CancellationToken)).ShouldHaveSingleItem(); + a.SecurityClass.ShouldNotContain("\n"); + a.SecurityClass.ShouldNotContain("\t"); + a.SecurityClass.ShouldNotContain(""); + a.SecurityClass.ShouldStartWith("line one line two tabbed"); + } + // ---------------------------------------------------------------- the browser [Fact] From abf3116bd41bbee81e80a541d8016a1dabc240f4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 16:40:31 -0400 Subject: [PATCH 15/43] =?UTF-8?q?fix(mqtt):=20wildcard=20tags=20went=20dar?= =?UTF-8?q?k=20on=20reconnect=20=E2=80=94=20index=20by=20filter,=20not=20t?= =?UTF-8?q?opic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C1 (CRITICAL). AuthoredTable routed any wildcard-authored tag into `Wildcards` and never into the concrete topic index. But the two paths that reconstruct state from a SUBSCRIBED FILTER STRING — OnReconnectedAsync's re-subscribe set and DegradeTopic — both looked up that concrete-only index, and `_subscribedTopics` keys on the wildcard PATTERN ("f/+/temp"). So: - reconnect resolved zero filters for a wildcard tag, hit a silent early return, and issued NO subscribe. The cache keeps its last Good value, so the tag never turns Bad — it just stops updating forever behind a connection reporting healthy. Exactly the silent-death mode MqttConnection's own remarks warn about, left unguarded for wildcards. - DegradeTopic missed too, so a SUBACK rejection of a wildcard filter degraded nothing and left the tag at BadWaitingForInitialData. Fix: AuthoredTable now carries `ByFilter` (EVERY def, keyed by its authored topic filter, wildcards included) alongside `ByExactTopic` (concrete only) and `Wildcards`. Delivery matches an incoming published topic — which never carries a wildcard — so it keeps using ByExactTopic + the comparer scan. Every path keyed on a filter string uses ByFilter. The distinction is documented on the record. The silent early return is now a Warning naming the orphaned topics. Also in this pass: - I2: bounded decode on the dispatcher thread. A body over `MaxPayloadBytes` (default 1 MiB, ctor-settable) is refused BEFORE any GetString/JsonDocument parse and degrades its own tags. Unbounded decode on the shared dispatcher is paid by every subscription, not just the offending topic. NOTE: promoting this to an operator-facing MqttDriverOptions key (+ WithMaximumPacketSize) needs a .Contracts edit this task is scoped out of; flagged in the XML docs. - I3: integral-valued reals now coerce to integer tags. JS/Python edge gateways serialize an integer as 5.0, and TryGetInt32/int.TryParse are syntactic — an Int32 tag fed by such a gateway silently never received data. Parsed via decimal so integrality and range are exact across Int64/UInt64. A genuinely fractional 5.5 is still refused, never rounded. - I4: the JSON document is parsed ONCE per message and shared across the whole fan-out (the documented "one document, one JSONPath per signal" shape was re-parsing per tag). Zero-alloc via a ref struct when no Json tag matches. - I5: pinned the documented JSON-string-holding-a-number coercion end-to-end. - Minor: Register now prunes `_subscribedTopics` / `_handleByRawPath` entries for tags a redeploy dropped, so a deleted topic stops being re-subscribed forever. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../MqttSubscriptionManager.cs | 467 ++++++++++++++---- .../MqttSubscriptionManagerTests.cs | 244 +++++++++ 2 files changed, 622 insertions(+), 89 deletions(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttSubscriptionManager.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttSubscriptionManager.cs index 8e8aa1e9..8438f456 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttSubscriptionManager.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttSubscriptionManager.cs @@ -59,6 +59,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt; /// public sealed class MqttSubscriptionManager { + /// + /// Default — 1 MiB. Generous for MQTT telemetry (which is + /// kilobytes) while still bounding the dispatcher-thread work any one publisher can impose. + /// + public const int DefaultMaxPayloadBytes = 1024 * 1024; + // OPC UA status codes (values verified against Opc.Ua.StatusCodes, not recalled). private const uint StatusGood = 0x00000000u; @@ -113,16 +119,23 @@ public sealed class MqttSubscriptionManager /// The last-value store backing IReadable. Defaults to a fresh one owned by this /// manager and exposed as . /// + /// + /// Ceiling on an inbound message body; see . Non-positive values + /// fall back to — "unbounded" is not offered, because the + /// bound exists to protect a shared dispatcher thread. + /// public MqttSubscriptionManager( MqttDriverOptions options, string driverId, IMqttSubscribeTransport? transport = null, ILogger? logger = null, - LastValueCache? cache = null) + LastValueCache? cache = null, + int maxPayloadBytes = DefaultMaxPayloadBytes) { ArgumentNullException.ThrowIfNull(options); ArgumentException.ThrowIfNullOrWhiteSpace(driverId); + MaxPayloadBytes = maxPayloadBytes > 0 ? maxPayloadBytes : DefaultMaxPayloadBytes; _mode = options.Mode; _defaultQos = options.Plain?.DefaultQos ?? 1; _driverId = driverId; @@ -142,6 +155,27 @@ public sealed class MqttSubscriptionManager /// public LastValueCache Values { get; } + /// + /// Ceiling on an inbound message body. A larger message is refused before any decode or parse + /// and degrades its own tags to BadDecodingError. + /// + /// + /// Decode and parse both run synchronously on MQTTnet's shared dispatcher thread, so their cost + /// is paid by every subscription, not just the offending topic. Without a bound, one + /// publisher shipping a multi-megabyte body — or a hostile one shipping a huge nested document + /// — stalls the whole driver's delivery for as long as the parse takes. Sized for telemetry + /// payloads, which are kilobytes; a deployment that legitimately needs more should raise this + /// deliberately rather than run unbounded. + /// + /// Follow-up: this is a constructor knob, not yet an operator-facing one. Promoting + /// it to an MqttDriverOptions key (and pairing it with MQTTnet's + /// WithMaximumPacketSize, so an oversized body is refused at the protocol layer + /// rather than after the client has already buffered it) needs an edit to the + /// .Contracts project that this task was scoped out of. + /// + /// + public int MaxPayloadBytes { get; } + /// /// Wires this manager onto a live connection: inbound messages route to /// and every reconnect re-establishes the subscriptions through @@ -197,8 +231,29 @@ public sealed class MqttSubscriptionManager } } - _authored = AuthoredTable.Build(byRawPath); + var table = AuthoredTable.Build(byRawPath); + _authored = table; _warnedRefs.Clear(); + + // Drop state belonging to tags this deploy removed. Without this, a topic whose last tag was + // deleted stays in _subscribedTopics and is dutifully re-SUBSCRIBEd on every future reconnect, + // forever — and its RawPaths keep a handle mapping that can never fire again. + foreach (var topic in _subscribedTopics.Keys) + { + if (!table.ByFilter.ContainsKey(topic)) + { + _subscribedTopics.TryRemove(topic, out _); + } + } + + foreach (var rawPath in _handleByRawPath.Keys) + { + if (!table.ByRawPath.ContainsKey(rawPath)) + { + _handleByRawPath.TryRemove(rawPath, out _); + } + } + return byRawPath.Count; } @@ -334,14 +389,38 @@ public sealed class MqttSubscriptionManager public async Task OnReconnectedAsync(CancellationToken cancellationToken) { var authored = _authored; - var live = _subscribedTopics.Keys - .Select(topic => authored.ByTopic.TryGetValue(topic, out var defs) ? defs : []) + var subscribed = _subscribedTopics.Keys.ToList(); + + // ByFilter, NOT ByExactTopic: these keys are subscribed FILTER strings, so a wildcard-authored + // tag's key is its pattern ("f/+/temp"). Reading the concrete-only index here misses every + // wildcard tag, drops it out of the re-subscribe set, and leaves it dark forever behind a + // connection that reports healthy — see the AuthoredTable remarks. + var live = subscribed + .Select(topic => authored.ByFilter.TryGetValue(topic, out var defs) ? defs : []) .SelectMany(defs => defs) .ToList(); var filters = BuildFilters(live, _defaultQos); if (filters.Count == 0) { + // Silence here is how the wildcard defect hid: no exception, no log, no SUBSCRIBE. If any + // topic was live, resolving zero filters for it is an anomaly and must be loud. + if (subscribed.Count > 0) + { + _logger?.LogWarning( + "MQTT driver '{DriverId}': reconnect resolved NO filters for {TopicCount} live topic(s) " + + "({Topics}); those subscriptions are not being re-established.", + _driverId, + subscribed.Count, + string.Join(", ", subscribed)); + } + else + { + _logger?.LogDebug( + "MQTT driver '{DriverId}': reconnect had no live subscriptions to re-establish.", + _driverId); + } + return; } @@ -361,9 +440,23 @@ public sealed class MqttSubscriptionManager /// currently subscribed. /// /// - /// Runs on MQTTnet's dispatcher thread: no I/O, no locks, no allocation beyond the extracted - /// value, and no path throws. A tag whose payload is malformed degrades to a Bad status - /// code and the fan-out continues to the next tag. + /// + /// Runs on MQTTnet's dispatcher thread: no I/O, no locks, and no path throws. A tag + /// whose payload is malformed degrades to a Bad status code and the fan-out continues to + /// the next tag. + /// + /// + /// Bounded work. A body larger than is refused + /// before any decode or parse: the whole point of the dispatcher discipline is that + /// one topic cannot stall delivery for every other subscription, and an unbounded + /// GetString / JsonDocument.Parse over a multi-megabyte body does exactly + /// that. The oversized message degrades its own tags to BadDecodingError. + /// + /// + /// Parsed once. The documented shape is several tags sharing one JSON document with + /// a JSONPath each, so the document is parsed at most once per message and shared across + /// the whole fan-out rather than re-parsed per tag. + /// /// /// The concrete topic the message arrived on. /// The message body; valid only for the duration of this call. @@ -377,25 +470,35 @@ public sealed class MqttSubscriptionManager var authored = _authored; var now = DateTime.UtcNow; + var oversized = payload.Length > MaxPayloadBytes; - // Concrete topics — the overwhelmingly common case — cost one lookup, so a chatty broker - // publishing thousands of topics this driver does not care about stays cheap. - if (authored.ByTopic.TryGetValue(topic, out var exact)) + // Zero-alloc until a Json-format tag actually matches; disposed in the finally below. + var json = default(PayloadJson); + try { - foreach (var def in exact) + // Concrete topics — the overwhelmingly common case — cost one lookup, so a chatty broker + // publishing thousands of topics this driver does not care about stays cheap. + if (authored.ByExactTopic.TryGetValue(topic, out var exact)) { - Deliver(def, payload, retained, now); + foreach (var def in exact) + { + Deliver(def, payload, retained, now, oversized, ref json); + } + } + + // A published topic name may not itself contain a wildcard, so a wildcard-authored + // definition can never also be an exact-index hit: no tag is delivered twice. + foreach (var def in authored.Wildcards) + { + if (MqttTopicFilterComparer.Compare(topic, def.Topic) == MqttTopicFilterCompareResult.IsMatch) + { + Deliver(def, payload, retained, now, oversized, ref json); + } } } - - // A published topic name may not itself contain a wildcard, so a wildcard-authored definition - // can never also be an exact-index hit: no tag is delivered twice. - foreach (var def in authored.Wildcards) + finally { - if (MqttTopicFilterComparer.Compare(topic, def.Topic) == MqttTopicFilterCompareResult.IsMatch) - { - Deliver(def, payload, retained, now); - } + json.Dispose(); } } @@ -509,14 +612,27 @@ public sealed class MqttSubscriptionManager } /// Marks every authored reference on as unreachable. + /// + /// is a subscribed filter string (it came from a requested + /// filter or a SUBACK outcome), so the lookup must go through ByFilter — the + /// concrete-only index misses every wildcard-authored tag and this method would then return + /// having silently degraded nothing, leaving the tag at BadWaitingForInitialData (or + /// worse, its last Good value) after the broker refused its subscription. + /// /// The topic filter whose subscription could not be established. /// The failure reason, for logs. private void DegradeTopic(string topic, string reason) { _subscribedTopics.TryRemove(topic, out _); - if (!_authored.ByTopic.TryGetValue(topic, out var defs)) + if (!_authored.ByFilter.TryGetValue(topic, out var defs)) { + _logger?.LogWarning( + "MQTT driver '{DriverId}': cannot degrade tags for unestablished filter '{Topic}' ({Reason}) — " + + "no authored tag resolves to it.", + _driverId, + topic, + reason); return; } @@ -544,7 +660,15 @@ public sealed class MqttSubscriptionManager /// The message body. /// The message's retain flag. /// One clock read shared by the whole fan-out, so siblings agree. - private void Deliver(MqttTagDefinition def, ReadOnlySpan payload, bool retained, DateTime nowUtc) + /// Whether the body exceeded . + /// The fan-out's shared JSON parse, created on first use. + private void Deliver( + MqttTagDefinition def, + ReadOnlySpan payload, + bool retained, + DateTime nowUtc, + bool oversized, + ref PayloadJson json) { // MQTT 3.1.1 has no retain-handling option, so the broker always replays its retained message // on subscribe; a tag that opted out of seeding is filtered here rather than at the broker. @@ -556,7 +680,11 @@ public sealed class MqttSubscriptionManager DataValueSnapshot snapshot; try { - snapshot = Extract(def, payload, nowUtc); + // The cap is enforced before any decode/parse, so an oversized body costs one comparison + // per matched tag rather than a full pass over the payload. + snapshot = oversized + ? new DataValueSnapshot(null, StatusBadDecodingError, null, nowUtc) + : Extract(def, payload, nowUtc, ref json); } catch (Exception ex) { @@ -623,13 +751,18 @@ public sealed class MqttSubscriptionManager /// The authored tag definition. /// The message body. /// Timestamp for the snapshot — MQTT carries no source timestamp of its own. + /// The fan-out's shared JSON parse, created on first use by a Json-format tag. /// The extracted snapshot. - internal static DataValueSnapshot Extract(MqttTagDefinition def, ReadOnlySpan payload, DateTime nowUtc) + internal static DataValueSnapshot Extract( + MqttTagDefinition def, + ReadOnlySpan payload, + DateTime nowUtc, + ref PayloadJson json) { switch (def.PayloadFormat) { case MqttPayloadFormat.Json: - return ExtractJson(def, payload, nowUtc); + return ExtractJson(def, payload, nowUtc, ref json); case MqttPayloadFormat.Raw: // "Bytes as-is": the payload's UTF-8 text, published verbatim. dataType is deliberately @@ -657,38 +790,81 @@ public sealed class MqttSubscriptionManager private static DataValueSnapshot Bad(uint status, DateTime nowUtc) => new(null, status, null, nowUtc); - /// Parses the payload as JSON, selects the tag's JSONPath, and coerces to its data type. + /// + /// Selects the tag's JSONPath out of the fan-out's shared parse and coerces it to the + /// tag's data type. The document is parsed at most once per message however many tags share the + /// topic — the documented "one JSON document, one JSONPath per signal" shape would otherwise + /// re-parse it once per tag, on the dispatcher thread. + /// /// The authored tag definition. /// The message body. /// Timestamp for the snapshot. + /// The shared parse; parses on first call and caches the outcome. /// The extracted snapshot. - private static DataValueSnapshot ExtractJson(MqttTagDefinition def, ReadOnlySpan payload, DateTime nowUtc) + private static DataValueSnapshot ExtractJson( + MqttTagDefinition def, + ReadOnlySpan payload, + DateTime nowUtc, + ref PayloadJson json) { - JsonDocument? doc = null; - try - { - var reader = new Utf8JsonReader(payload); - if (!JsonDocument.TryParseValue(ref reader, out doc)) - { - return Bad(StatusBadDecodingError, nowUtc); - } - - if (!TryEvaluateJsonPath(doc.RootElement, def.JsonPath, out var selected)) - { - return Bad(StatusBadDecodingError, nowUtc); - } - - return TryCoerce(selected, def.DataType, out var value) - ? new DataValueSnapshot(value, StatusGood, nowUtc, nowUtc) - : Bad(StatusBadTypeMismatch, nowUtc); - } - catch (JsonException) + if (!json.TryGetRoot(payload, out var root)) { return Bad(StatusBadDecodingError, nowUtc); } - finally + + if (!TryEvaluateJsonPath(root, def.JsonPath, out var selected)) { - doc?.Dispose(); + return Bad(StatusBadDecodingError, nowUtc); + } + + return TryCoerce(selected, def.DataType, out var value) + ? new DataValueSnapshot(value, StatusGood, nowUtc, nowUtc) + : Bad(StatusBadTypeMismatch, nowUtc); + } + + /// + /// One message's JSON parse, shared across the whole fan-out. A mutable struct passed by + /// ref so the common case — a message no Json-format tag matched — allocates nothing at + /// all, while a message several Json tags share is parsed exactly once. + /// + internal struct PayloadJson : IDisposable + { + private JsonDocument? _doc; + private bool _attempted; + private bool _parsed; + + /// + /// The parsed document root, parsing on first call. A malformed payload is remembered as a + /// failure so the fan-out's remaining tags do not each retry the same doomed parse. + /// + /// The message body; only read on the first call. + /// The document root when this returns . + /// when the payload is valid JSON. + public bool TryGetRoot(ReadOnlySpan payload, out JsonElement root) + { + if (!_attempted) + { + _attempted = true; + try + { + var reader = new Utf8JsonReader(payload); + _parsed = JsonDocument.TryParseValue(ref reader, out _doc); + } + catch (JsonException) + { + _parsed = false; + } + } + + root = _parsed ? _doc!.RootElement : default; + return _parsed; + } + + /// + public void Dispose() + { + _doc?.Dispose(); + _doc = null; } } @@ -830,28 +1006,28 @@ public sealed class MqttSubscriptionManager return false; case DriverDataType.Int16: - if (element.ValueKind == JsonValueKind.Number && element.TryGetInt16(out var i16)) { value = i16; return true; } - return false; - case DriverDataType.Int32: - if (element.ValueKind == JsonValueKind.Number && element.TryGetInt32(out var i32)) { value = i32; return true; } - return false; - case DriverDataType.Int64: - if (element.ValueKind == JsonValueKind.Number && element.TryGetInt64(out var i64)) { value = i64; return true; } - return false; - case DriverDataType.UInt16: - if (element.ValueKind == JsonValueKind.Number && element.TryGetUInt16(out var u16)) { value = u16; return true; } - return false; - case DriverDataType.UInt32: - if (element.ValueKind == JsonValueKind.Number && element.TryGetUInt32(out var u32)) { value = u32; return true; } - return false; - case DriverDataType.UInt64: - if (element.ValueKind == JsonValueKind.Number && element.TryGetUInt64(out var u64)) { value = u64; return true; } - return false; + if (element.ValueKind != JsonValueKind.Number) + { + return false; + } + + // Fast path: the JSON number is already written as an integer. + if (TryExactInteger(element, type, out value)) + { + return true; + } + + // Fallback: an integral-valued REAL. JS/Python edge gateways routinely serialize an + // integer as 5.0 (or 5E2), and System.Text.Json's TryGetInt32/64 are syntactic — they + // refuse it. Refusing here would mean an integer tag fed by such a gateway silently + // never receives data, so an exactly-integral real is accepted; a genuinely fractional + // value (5.5) is still refused rather than rounded. + return TryIntegralReal(element.GetRawText(), type, out value); case DriverDataType.Float32: if (element.ValueKind == JsonValueKind.Number && element.TryGetSingle(out var f32)) { value = f32; return true; } @@ -896,28 +1072,19 @@ public sealed class MqttSubscriptionManager return false; case DriverDataType.Int16: - if (short.TryParse(trimmed, NumberStyles.Integer, inv, out var i16)) { value = i16; return true; } - return false; - case DriverDataType.Int32: - if (int.TryParse(trimmed, NumberStyles.Integer, inv, out var i32)) { value = i32; return true; } - return false; - case DriverDataType.Int64: - if (long.TryParse(trimmed, NumberStyles.Integer, inv, out var i64)) { value = i64; return true; } - return false; - case DriverDataType.UInt16: - if (ushort.TryParse(trimmed, NumberStyles.Integer, inv, out var u16)) { value = u16; return true; } - return false; - case DriverDataType.UInt32: - if (uint.TryParse(trimmed, NumberStyles.Integer, inv, out var u32)) { value = u32; return true; } - return false; - case DriverDataType.UInt64: - if (ulong.TryParse(trimmed, NumberStyles.Integer, inv, out var u64)) { value = u64; return true; } - return false; + // Same two-step as the JSON path: exact integer text first, then an integral-valued + // real ("5.0"), which a Scalar-format edge gateway emits just as readily. + if (TryExactIntegerText(trimmed, type, out value)) + { + return true; + } + + return TryIntegralReal(trimmed, type, out value); case DriverDataType.Float32: if (float.TryParse(trimmed, NumberStyles.Float, inv, out var f32)) { value = f32; return true; } @@ -945,6 +1112,95 @@ public sealed class MqttSubscriptionManager } } + /// Reads a JSON number that is written as an exact integer of the requested type. + /// The JSON number. + /// The target integer type. + /// The boxed value when this returns . + /// when the number is an exact integer in range. + private static bool TryExactInteger(JsonElement element, DriverDataType type, out object? value) + { + value = type switch + { + DriverDataType.Int16 => element.TryGetInt16(out var v) ? v : null, + DriverDataType.Int32 => element.TryGetInt32(out var v) ? v : null, + DriverDataType.Int64 => element.TryGetInt64(out var v) ? v : null, + DriverDataType.UInt16 => element.TryGetUInt16(out var v) ? v : null, + DriverDataType.UInt32 => element.TryGetUInt32(out var v) ? v : null, + DriverDataType.UInt64 => element.TryGetUInt64(out var v) ? v : (object?)null, + _ => null, + }; + + return value is not null; + } + + /// Parses text written as an exact integer of the requested type. + /// The trimmed text. + /// The target integer type. + /// The boxed value when this returns . + /// when the text is an exact integer in range. + private static bool TryExactIntegerText(string text, DriverDataType type, out object? value) + { + var inv = CultureInfo.InvariantCulture; + value = type switch + { + DriverDataType.Int16 => short.TryParse(text, NumberStyles.Integer, inv, out var v) ? v : null, + DriverDataType.Int32 => int.TryParse(text, NumberStyles.Integer, inv, out var v) ? v : null, + DriverDataType.Int64 => long.TryParse(text, NumberStyles.Integer, inv, out var v) ? v : null, + DriverDataType.UInt16 => ushort.TryParse(text, NumberStyles.Integer, inv, out var v) ? v : null, + DriverDataType.UInt32 => uint.TryParse(text, NumberStyles.Integer, inv, out var v) ? v : null, + DriverDataType.UInt64 => ulong.TryParse(text, NumberStyles.Integer, inv, out var v) ? v : (object?)null, + _ => null, + }; + + return value is not null; + } + + /// + /// Accepts a real that is exactly integral (5.0, 5E2) as an integer of the + /// requested type. A fractional value is refused, never rounded — silently turning 5.5 into 5 + /// or 6 would be a wrong value, which is worse than no value. + /// + /// + /// Parsed as rather than so the integrality test and + /// the conversion are exact across the whole Int64/UInt64 range, where double loses precision + /// above 2^53. Out-of-range values overflow the conversion and are refused. + /// + /// The number's text (JSON raw text, or a Scalar payload). + /// The target integer type. + /// The boxed value when this returns . + /// when the text is an integral real in range. + private static bool TryIntegralReal(string text, DriverDataType type, out object? value) + { + value = null; + + if (!decimal.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var d) + || d != decimal.Truncate(d)) + { + return false; + } + + try + { + value = type switch + { + DriverDataType.Int16 => (short)d, + DriverDataType.Int32 => (int)d, + DriverDataType.Int64 => (long)d, + DriverDataType.UInt16 => (ushort)d, + DriverDataType.UInt32 => (uint)d, + DriverDataType.UInt64 => (ulong)d, + _ => null, + }; + } + catch (OverflowException) + { + // decimal → integer conversions throw rather than wrap; out of range is a refusal. + value = null; + } + + return value is not null; + } + /// /// Decodes a payload as UTF-8, refusing invalid byte sequences rather than rendering a genuinely /// binary body as a field of replacement characters (the same choice the browse session makes). @@ -990,32 +1246,64 @@ public sealed class MqttSubscriptionManager private sealed record MqttSubscriptionHandle(string DiagnosticId) : ISubscriptionHandle; /// - /// The authored tag table and its derived topic index, published as one immutable snapshot so - /// the dispatcher thread reads a consistent view while a redeploy rebuilds it. + /// The authored tag table and its derived indexes, published as one immutable snapshot so the + /// dispatcher thread reads a consistent view while a redeploy rebuilds it. /// + /// + /// Two topic indexes, and the difference is load-bearing. Delivery matches an + /// incoming published topic, which never carries a wildcard, so it uses + /// (concrete only) plus a comparer scan of . + /// Every path that instead reconstructs state from a subscribed filter string — + /// reconnect re-subscribe and per-topic degradation — must use , which is + /// keyed by the tag's authored topic including wildcard patterns, because that is what + /// lands in _subscribedTopics and in a SUBACK outcome. Looking a wildcard filter up in + /// the concrete index always misses, and a miss on those paths is silent: the tag simply stops + /// updating behind a healthy-looking connection, keeping its last Good value forever. + /// /// RawPath → definition; the v3 resolver's backing table. - /// Concrete topic → every definition bound to it (the fan-out list). + /// + /// Authored topic filter string → every definition bound to it, wildcard patterns + /// included. Keyed the same way _subscribedTopics and SUBACK outcomes are. + /// + /// Concrete topic → its definitions; the delivery fast path. /// Definitions whose authored topic carries an MQTT wildcard. private sealed record AuthoredTable( FrozenDictionary ByRawPath, - FrozenDictionary ByTopic, + FrozenDictionary ByFilter, + FrozenDictionary ByExactTopic, MqttTagDefinition[] Wildcards) { public static readonly AuthoredTable Empty = Build(new Dictionary(StringComparer.Ordinal)); - /// Builds the snapshot, splitting concrete topics from wildcard-authored ones. + /// True when a topic string carries an MQTT wildcard and is therefore a filter, not a name. + /// The authored topic. + /// when it contains + or #. + public static bool IsWildcard(string topic) => + topic.Contains(MqttTopicFilterComparer.SingleLevelWildcard, StringComparison.Ordinal) + || topic.Contains(MqttTopicFilterComparer.MultiLevelWildcard, StringComparison.Ordinal); + + /// Builds the snapshot: every def into ByFilter, concrete/wildcard split for delivery. /// The mapped definitions, keyed by RawPath. /// The immutable snapshot. public static AuthoredTable Build(IReadOnlyDictionary byRawPath) { + var byFilter = new Dictionary>(StringComparer.Ordinal); var concrete = new Dictionary>(StringComparer.Ordinal); var wildcards = new List(); foreach (var def in byRawPath.Values) { - if (def.Topic.Contains(MqttTopicFilterComparer.SingleLevelWildcard, StringComparison.Ordinal) - || def.Topic.Contains(MqttTopicFilterComparer.MultiLevelWildcard, StringComparison.Ordinal)) + // EVERY def lands here, wildcard or not — this is the index the reconnect and degrade + // paths read, and omitting wildcards from it is exactly the C1 silent-death defect. + if (!byFilter.TryGetValue(def.Topic, out var all)) + { + byFilter[def.Topic] = all = []; + } + + all.Add(def); + + if (IsWildcard(def.Topic)) { wildcards.Add(def); continue; @@ -1031,6 +1319,7 @@ public sealed class MqttSubscriptionManager return new AuthoredTable( byRawPath.ToFrozenDictionary(StringComparer.Ordinal), + byFilter.ToFrozenDictionary(kv => kv.Key, kv => kv.Value.ToArray(), StringComparer.Ordinal), concrete.ToFrozenDictionary(kv => kv.Key, kv => kv.Value.ToArray(), StringComparer.Ordinal), [.. wildcards]); } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttSubscriptionManagerTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttSubscriptionManagerTests.cs index c0f7b3f7..5fc1e966 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttSubscriptionManagerTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttSubscriptionManagerTests.cs @@ -583,6 +583,250 @@ public sealed class MqttSubscriptionManagerTests mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(Good); } + // --------------------------------------------------------------------------------- + // C1 — wildcard-authored tags on the paths that reconstruct state from a FILTER string. + // Delivery matches an incoming topic (concrete); reconnect + degrade key off the subscribed + // filter, which for a wildcard tag is its PATTERN. Reading the concrete-only index there + // silently drops the tag: no exception, no log, last Good value frozen forever. + // --------------------------------------------------------------------------------- + + [Fact] + public async Task OnReconnectedAsync_WildcardAuthoredTopic_IsReSubscribed() + { + var transport = new FakeTransport(); + var mgr = NewManager(transport); + mgr.Register([Tag(OvenTempPath, """{"topic":"f/+/temp","payloadFormat":"Scalar","dataType":"Float64"}""")]); + await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + transport.Calls.Count.ShouldBe(1); + + await mgr.OnReconnectedAsync(TestContext.Current.CancellationToken); + + transport.Calls.Count.ShouldBe(2); + transport.Calls[1].Select(f => f.Topic).ShouldBe(["f/+/temp"]); + } + + /// + /// The end-to-end shape of the defect: after a reconnect the wildcard tag must still receive + /// data. A dropped re-subscribe is invisible in every other assertion — the cache keeps its last + /// Good value, so nothing turns Bad. + /// + [Fact] + public async Task OnReconnectedAsync_WildcardAuthoredTopic_KeepsDeliveringAfterwards() + { + var transport = new FakeTransport(); + var mgr = NewManager(transport); + mgr.Register([Tag(OvenTempPath, """{"topic":"f/+/temp","payloadFormat":"Scalar","dataType":"Float64"}""")]); + await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + + await mgr.OnReconnectedAsync(TestContext.Current.CancellationToken); + + // The broker only forwards what it was re-subscribed to; model that by asserting the filter is + // live, then that a matching publish still lands. + transport.Calls[^1].Select(f => f.Topic).ShouldContain("f/+/temp"); + + object? got = null; + mgr.OnDataChange += (_, e) => got = e.Snapshot.Value; + mgr.HandleMessage("f/oven/temp", "9.5"u8.ToArray(), retained: false); + got.ShouldBe(9.5); + } + + /// + /// A total re-subscribe failure must throw for a wildcard-only driver too. Before the fix the + /// wildcard tag never reached the filter set, so filters.Count == 0 took a silent early + /// return and the caller was told everything was fine. + /// + [Fact] + public async Task OnReconnectedAsync_WildcardOnly_TotalFailure_StillThrows() + { + var transport = new FakeTransport(); + var mgr = NewManager(transport); + mgr.Register([Tag(OvenTempPath, """{"topic":"f/+/temp","payloadFormat":"Scalar","dataType":"Float64"}""")]); + await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + + transport.Throw = new InvalidOperationException("broker gone"); + + await Should.ThrowAsync(() => mgr.OnReconnectedAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task SubscribeAsync_WildcardAuthoredTopic_SubackRejection_DegradesItsRefs() + { + var transport = new FakeTransport { Reject = _ => true }; + var mgr = NewManager(transport); + mgr.Register([Tag(OvenTempPath, """{"topic":"f/+/temp","payloadFormat":"Scalar","dataType":"Float64"}""")]); + + await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + + // Before the fix this stayed at BadWaitingForInitialData — the broker refused the subscription + // and the tag never learned it was unreachable. + mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(BadCommunicationError); + } + + [Fact] + public async Task OnReconnectedAsync_WildcardAuthoredTopic_PartialRejection_DegradesTheWildcardRefs() + { + var transport = new FakeTransport(); + var mgr = NewManager(transport); + mgr.Register( + [ + Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""), + Tag(OvenPressPath, """{"topic":"f/+/press","payloadFormat":"Scalar","dataType":"Float64"}"""), + ]); + await mgr.SubscribeAsync([OvenTempPath, OvenPressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + mgr.HandleMessage(OvenTopic, "1"u8.ToArray(), retained: false); + + transport.Reject = t => t == "f/+/press"; + + await Should.NotThrowAsync(() => mgr.OnReconnectedAsync(TestContext.Current.CancellationToken)); + + mgr.Values.Read(OvenPressPath).StatusCode.ShouldBe(BadCommunicationError); + mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(Good); + } + + // --------------------------------------------------------------------------------- + // I2 — bounded decode on the dispatcher thread + // --------------------------------------------------------------------------------- + + [Fact] + public async Task HandleMessage_PayloadOverTheCeiling_IsRefusedBeforeDecode() + { + var mgr = new MqttSubscriptionManager( + new MqttDriverOptions { Mode = MqttMode.Plain }, "mqtt-1", maxPayloadBytes: 16); + mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"String"}""")]); + await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + + DataValueSnapshot? snap = null; + mgr.OnDataChange += (_, e) => snap = e.Snapshot; + + mgr.HandleMessage(OvenTopic, new byte[17], retained: false); + snap!.StatusCode.ShouldBe(BadDecodingError); + + // …and a body at the ceiling still flows. + mgr.HandleMessage(OvenTopic, "0123456789ABCDEF"u8.ToArray(), retained: false); + snap.StatusCode.ShouldBe(Good); + } + + [Fact] + public void MaxPayloadBytes_NonPositive_FallsBackToTheDefault() => + new MqttSubscriptionManager(new MqttDriverOptions(), "d", maxPayloadBytes: 0) + .MaxPayloadBytes.ShouldBe(MqttSubscriptionManager.DefaultMaxPayloadBytes); + + // --------------------------------------------------------------------------------- + // I3 — integral-valued reals against integer tags + // --------------------------------------------------------------------------------- + + [Theory] + [InlineData("Int32", """{"value":5.0}""", 5)] + [InlineData("Int32", """{"value":5}""", 5)] + [InlineData("Int64", """{"value":5.0}""", 5L)] + [InlineData("Int16", """{"value":5.0}""", (short)5)] + [InlineData("UInt32", """{"value":5.0}""", 5u)] + public async Task HandleMessage_Json_IntegralValuedReal_CoercesToIntegerTag( + string dataType, string payload, object expected) + { + var mgr = NewManager(); + mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"{{dataType}}"}""")]); + await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + + DataValueSnapshot? snap = null; + mgr.OnDataChange += (_, e) => snap = e.Snapshot; + + mgr.HandleMessage(OvenTopic, System.Text.Encoding.UTF8.GetBytes(payload), retained: false); + + snap!.StatusCode.ShouldBe(Good); + snap.Value.ShouldBe(expected); + } + + /// A genuinely fractional value is still refused — never rounded into a wrong value. + [Fact] + public async Task HandleMessage_Json_FractionalReal_StillRefusedByIntegerTag() + { + var mgr = NewManager(); + mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"Int32"}""")]); + await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + + DataValueSnapshot? snap = null; + mgr.OnDataChange += (_, e) => snap = e.Snapshot; + + mgr.HandleMessage(OvenTopic, """{"value":5.5}"""u8.ToArray(), retained: false); + + snap!.StatusCode.ShouldBe(BadTypeMismatch); + snap.Value.ShouldBeNull(); + } + + [Fact] + public async Task HandleMessage_Json_OutOfRangeIntegralReal_IsRefused() + { + var mgr = NewManager(); + mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"Int16"}""")]); + await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + + DataValueSnapshot? snap = null; + mgr.OnDataChange += (_, e) => snap = e.Snapshot; + + mgr.HandleMessage(OvenTopic, """{"value":99999.0}"""u8.ToArray(), retained: false); + + snap!.StatusCode.ShouldBe(BadTypeMismatch); + } + + [Fact] + public async Task HandleMessage_Scalar_IntegralValuedReal_CoercesToIntegerTag() + { + var mgr = NewManager(); + mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Int32"}""")]); + await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + + DataValueSnapshot? snap = null; + mgr.OnDataChange += (_, e) => snap = e.Snapshot; + + mgr.HandleMessage(OvenTopic, "5.0"u8.ToArray(), retained: false); + + snap!.StatusCode.ShouldBe(Good); + snap.Value.ShouldBe(5); + } + + // --------------------------------------------------------------------------------- + // I5 — a JSON string holding a number, against a numeric tag (documented as supported) + // --------------------------------------------------------------------------------- + + [Theory] + [InlineData("Float64", """{"value":"21.5"}""", 21.5)] + [InlineData("Int32", """{"value":"5"}""", 5)] + [InlineData("Boolean", """{"value":"true"}""", true)] + public async Task HandleMessage_Json_StringHoldingANumber_CoercesToTheNumericTag( + string dataType, string payload, object expected) + { + var mgr = NewManager(); + mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"{{dataType}}"}""")]); + await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + + DataValueSnapshot? snap = null; + mgr.OnDataChange += (_, e) => snap = e.Snapshot; + + mgr.HandleMessage(OvenTopic, System.Text.Encoding.UTF8.GetBytes(payload), retained: false); + + snap!.StatusCode.ShouldBe(Good); + snap.Value.ShouldBe(expected); + } + + // --------------------------------------------------------------------------------- + // Stale-state pruning on redeploy + // --------------------------------------------------------------------------------- + + [Fact] + public async Task Register_DroppingATag_StopsItsTopicBeingReSubscribedForever() + { + var transport = new FakeTransport(); + var mgr = NewManager(transport); + mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]); + await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + + mgr.Register([]); // redeploy drops the tag + await mgr.OnReconnectedAsync(TestContext.Current.CancellationToken); + + transport.Calls.Count.ShouldBe(1); // the initial subscribe only — no zombie re-subscribe + } + /// Records every subscribe call so dedupe / re-subscribe can be asserted without a broker. private sealed class FakeTransport : IMqttSubscribeTransport { From a856d61510af0a40223fdbe02513c6d1e4da1885 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 16:43:36 -0400 Subject: [PATCH 16/43] feat(mqtt): MqttDriverProbe CONNECT handshake Test-connect probe for the Mqtt driver type: parses MqttDriverOptions (shared enum-as-name JsonSerializerOptions), opens a bounded MQTT CONNECT via MqttConnection.BuildClientOptions with a distinct -probe-{guid8} client-id suffix, and classifies the outcome. Live-probed against MQTTnet 5.2.0.1603 to confirm the real contract: a broker-rejected CONNACK is a MqttClientConnectResult.ResultCode, never a thrown exception, and timeout classification checks the deadline token itself rather than switching on exception type (which varies unpredictably between MqttConnectingFailedException and bare OperationCanceledException for the same frozen-peer shape). Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../MqttDriverProbe.cs | 244 ++++++++++++++++++ .../MqttDriverProbeTests.cs | 182 +++++++++++++ 2 files changed, 426 insertions(+) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverProbe.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverProbeTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverProbe.cs new file mode 100644 index 00000000..ce8a71cc --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverProbe.cs @@ -0,0 +1,244 @@ +using System.Diagnostics; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging.Abstractions; +using MQTTnet; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt; + +/// +/// CONNECT-handshake probe for the -shaped driver config. +/// Opens a bounded MQTT CONNECT against the configured broker; a CONNACK-accepted response +/// (MqttClientConnectResultCode.Success) is the "device is answering" proof (green + +/// latency). Refused/TLS/auth/timeout failures each surface a targeted message. Mirrors +/// ModbusDriverProbe's shape. +/// +/// +/// +/// Reuses for the connect (TLS, +/// CA-pin, credentials, protocol-version mapping) rather than hand-rolling a second +/// connect path — a duplicate would be a security divergence. A distinct client-id suffix +/// (see ) keeps a probe from colliding with the driver's +/// own session: an MQTT broker disconnects an existing client when a new one CONNECTs +/// with the same client id, so a probe that reused the driver's id would knock the +/// running driver offline every time an operator clicked "Test connect". +/// +/// +/// A broker-rejected CONNACK is not a thrown exception. Live-probed against +/// MQTTnet 5.2.0.1603: IMqttClient.ConnectAsync returns a +/// MqttClientConnectResult whose ResultCode carries the broker's CONNACK +/// reason (e.g. NotAuthorized, BadUserNameOrPassword) — it does +/// not throw for a rejected CONNACK. Only transport-level failures throw, and +/// their shape varies by where the failure occurs: a closed/refused port throws +/// MqttCommunicationException wrapping a ; an +/// untrusted broker certificate throws MqttCommunicationException wrapping an +/// ; a broker that accepts the TCP handshake and +/// then never answers CONNACK throws either MqttConnectingFailedException +/// (wrapping "Connection closed") or a bare +/// ("MQTT connect canceled"), inconsistently, depending on whether MQTTnet's own +/// internal Timeout or the deadline token wins the race. Consequently this probe — +/// like — never switches on exception type to +/// detect a timeout; it checks the deadline token itself, which is authoritative +/// regardless of which exception shape MQTTnet happened to throw. +/// +/// +public sealed class MqttDriverProbe : IDriverProbe +{ + /// + /// Marks the transient probe identity in the broker's client-id/session logs — mirrors the + /// browser's own -browse-{guid8} suffix so the two transient sessions are + /// distinguishable in broker-side logs, and so a probe can never collide with (and knock + /// offline) the driver's own live session. + /// + internal const string ProbeClientIdPrefix = "-probe-"; + + /// + /// Shared JSON options for every MQTT config-parsing seam in this driver: enums + /// (, , protocolVersion) + /// round-trip by name, never ordinal — the repo's documented enum-serialization + /// trap (AdminUI-authored configs with numeric enum fields fault the driver). Task 9's + /// MqttDriverFactoryExtensions is expected to converge on this same instance + /// rather than defining its own. + /// + internal static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNameCaseInsensitive = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + Converters = { new JsonStringEnumConverter() }, + }; + + /// + // Literal rather than a constant: DriverTypeNames.Mqtt does not exist yet — Task 9 adds it, and + // owns that file. The string must stay EXACTLY "Mqtt": a DriverType string that drifts from the + // persisted one silently breaks driver dispatch (the repo's ModbusTcp/Modbus incident). + public string DriverType => "Mqtt"; + + /// + public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) + { + MqttDriverOptions? options; + try + { + options = JsonSerializer.Deserialize(configJson, JsonOpts); + } + catch (Exception ex) + { + return new DriverProbeResult(false, $"Config JSON is invalid: {ex.Message}", null); + } + + if (options is null) + { + return new DriverProbeResult(false, "Config JSON deserialized to null.", null); + } + + if (string.IsNullOrWhiteSpace(options.Host) || options.Port is <= 0 or > 65535) + { + return new DriverProbeResult(false, "Config has no host/port to probe.", null); + } + + // Honour the config's own connectTimeoutSeconds (factory parity, mirrors + // ModbusDriverProbe) — the caller's timeout is the fallback when the config omits it. + var effectiveSeconds = options.ConnectTimeoutSeconds > 0 + ? options.ConnectTimeoutSeconds + : (int)Math.Ceiling(timeout.TotalSeconds); + if (effectiveSeconds <= 0) + { + effectiveSeconds = 1; + } + + // Rebuild with the resolved timeout so BuildClientOptions' own WithTimeout(...) and this + // method's deadline agree — both must expire at the same instant for the deadline check + // below to be a reliable signal regardless of which of the two actually threw. + var probeOptions = options with { ConnectTimeoutSeconds = effectiveSeconds }; + + MqttClientOptions clientOptions; + try + { + clientOptions = MqttConnection.BuildClientOptions( + probeOptions, + ProbeClientIdPrefix + Guid.NewGuid().ToString("N")[..8], + NullLogger.Instance); + } + catch (Exception ex) + { + // Never let a configured Password reach the message — BuildClientOptions itself never + // logs it, and no exception path through it can format credential values. + return new DriverProbeResult(false, $"Config could not be used to build a connection: {ex.Message}", null); + } + + var target = $"{probeOptions.Host}:{probeOptions.Port}"; + var sw = Stopwatch.StartNew(); + + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(effectiveSeconds)); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, deadline.Token); + + using var client = new MqttClientFactory().CreateMqttClient(); + + MqttClientConnectResult result; + try + { + result = await client.ConnectAsync(clientOptions, linked.Token).ConfigureAwait(false); + } + catch (Exception ex) + { + return new DriverProbeResult(false, Classify(target, ex, ct, deadline, effectiveSeconds), null); + } + + try + { + if (result.ResultCode != MqttClientConnectResultCode.Success) + { + return new DriverProbeResult(false, ClassifyRejectedConnack(target, result.ResultCode), null); + } + + sw.Stop(); + return new DriverProbeResult(true, "MQTT CONNECT OK", sw.Elapsed); + } + finally + { + await DisconnectBestEffortAsync(client, effectiveSeconds).ConfigureAwait(false); + } + } + + /// + /// Maps a broker-rejected CONNACK ( not + /// Success — never an exception, see the type remarks) to a targeted, credential-free + /// message. + /// + private static string ClassifyRejectedConnack(string target, MqttClientConnectResultCode code) => code switch + { + MqttClientConnectResultCode.NotAuthorized or MqttClientConnectResultCode.BadUserNameOrPassword + => $"Broker at {target} rejected the credentials ({code}).", + MqttClientConnectResultCode.UnsupportedProtocolVersion + => $"Broker at {target} does not support the configured protocol version ({code}).", + MqttClientConnectResultCode.Banned + => $"Broker at {target} has banned this client ({code}).", + _ => $"Broker at {target} refused CONNECT: {code}.", + }; + + /// + /// Classifies a thrown connect failure. Precedence mirrors + /// : caller cancellation, then the deadline, then the + /// exception's own shape — the deadline check is authoritative regardless of which exception + /// type MQTTnet happened to throw (see the type remarks for why exception type alone is not + /// reliable here). + /// + private static string Classify( + string target, + Exception ex, + CancellationToken callerToken, + CancellationTokenSource deadline, + int effectiveSeconds) + { + if (callerToken.IsCancellationRequested) + { + return "Probe was cancelled."; + } + + if (deadline.IsCancellationRequested) + { + return $"Probe timed out after {effectiveSeconds}s."; + } + + // Walk to the root cause — MQTTnet wraps transport/TLS failures one or two levels deep. + var root = ex; + while (root.InnerException is not null) + { + root = root.InnerException; + } + + return root switch + { + SocketException se => $"Connect to {target} failed: {se.SocketErrorCode}", + AuthenticationException => $"TLS handshake with {target} failed: {root.Message}", + _ => $"Connect to {target} failed: {root.Message}", + }; + } + + /// + /// Best-effort teardown of a session this probe established. Failure here is not the + /// probe's business — the CONNECT outcome already decided Ok/failure — and it must never be + /// allowed to hang past the connect budget. + /// + private static async Task DisconnectBestEffortAsync(IMqttClient client, int timeoutSeconds) + { + if (!client.IsConnected) + { + return; + } + + try + { + using var disconnectDeadline = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds)); + await client.DisconnectAsync(new MqttClientDisconnectOptions(), disconnectDeadline.Token) + .ConfigureAwait(false); + } + catch + { + // Best-effort only. + } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverProbeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverProbeTests.cs new file mode 100644 index 00000000..14c70eab --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverProbeTests.cs @@ -0,0 +1,182 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests; + +/// +/// Tests for . Mirrors ModbusDriverProbeTests' +/// shape: a real loopback listener drives server-side behaviour so the probe's CONNECT +/// handshake is exercised against something real rather than mocked. +/// +[Trait("Category", "Unit")] +public sealed class MqttDriverProbeTests +{ + private static readonly TimeSpan QuickTimeout = TimeSpan.FromSeconds(3); + + /// A loopback listener that accepts the TCP connection and then never answers CONNACK — + /// the shape that actually wedges a client. A refused port fails instantly with ECONNREFUSED and + /// would pass whether or not the probe's deadline logic works at all, so the timeout test below + /// needs this rather than a closed port. + private sealed class BlackholeBroker : IDisposable + { + private readonly TcpListener _listener; + private readonly CancellationTokenSource _cts = new(); + private readonly List _accepted = []; + + public BlackholeBroker() + { + _listener = new TcpListener(IPAddress.Loopback, 0); + _listener.Start(); + Port = ((IPEndPoint)_listener.LocalEndpoint).Port; + _ = Task.Run(AcceptLoopAsync); + } + + public int Port { get; } + + private async Task AcceptLoopAsync() + { + try + { + while (!_cts.IsCancellationRequested) + { + var client = await _listener.AcceptTcpClientAsync(_cts.Token).ConfigureAwait(false); + lock (_accepted) + { + _accepted.Add(client); + } + // Accept the TCP handshake and then say nothing — never a CONNACK. + } + } + catch (Exception) + { + // Listener stopped / cancelled — expected at teardown. + } + } + + public void Dispose() + { + _cts.Cancel(); + _listener.Stop(); + lock (_accepted) + { + foreach (var client in _accepted) + { + client.Dispose(); + } + } + _cts.Dispose(); + } + } + + private static TcpListener StartListener() + { + var l = new TcpListener(IPAddress.Loopback, 0); + l.Start(); + return l; + } + + private static int ListenerPort(TcpListener l) => ((IPEndPoint)l.LocalEndpoint).Port; + + [Fact] + public void DriverType_IsCanonicalMqtt() => new MqttDriverProbe().DriverType.ShouldBe("Mqtt"); + + /// The plan's own vacuous-pass trap: a closed port fails instantly (ECONNREFUSED) + /// regardless of whether the deadline logic works. Kept as the "refused" leg, not the + /// "deadline" leg — see + /// for the real timeout-path exercise. + [Fact] + public async Task ProbeAsync_DeadBroker_ReturnsFailedResult_WithinDeadline() + { + var listener = StartListener(); + var port = ListenerPort(listener); + listener.Stop(); + + var r = await new MqttDriverProbe().ProbeAsync( + $$"""{"host":"127.0.0.1","port":{{port}},"useTls":false,"connectTimeoutSeconds":2}""", + QuickTimeout, + CancellationToken.None); + + r.Ok.ShouldBeFalse(); + r.Message.ShouldNotBeNullOrEmpty(); + } + + /// The real timeout-path exercise: a broker that completes the TCP handshake and then + /// never answers CONNACK must still fail at the configured deadline rather than hang. + [Fact] + public async Task ProbeAsync_BlackholeBroker_FailsWithinDeadline_NoHang() + { + using var blackhole = new BlackholeBroker(); + var config = $$"""{"host":"127.0.0.1","port":{{blackhole.Port}},"useTls":false,"connectTimeoutSeconds":1}"""; + + var sw = Stopwatch.StartNew(); + var r = await new MqttDriverProbe().ProbeAsync(config, QuickTimeout, CancellationToken.None); + sw.Stop(); + + r.Ok.ShouldBeFalse(); + r.Message.ShouldNotBeNullOrEmpty(); + r.Message.ShouldContain("timed out", Case.Insensitive); + sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10)); + } + + [Fact] + public async Task ProbeAsync_InvalidJson_ReturnsFailedResult_NotThrown() + { + var r = await new MqttDriverProbe().ProbeAsync("not valid json {{", QuickTimeout, CancellationToken.None); + + r.Ok.ShouldBeFalse(); + r.Message.ShouldNotBeNullOrEmpty(); + } + + [Fact] + public async Task ProbeAsync_NoHost_ReturnsFailedResult() + { + var r = await new MqttDriverProbe().ProbeAsync( + """{"host":"","port":0}""", QuickTimeout, CancellationToken.None); + + r.Ok.ShouldBeFalse(); + r.Message.ShouldNotBeNullOrEmpty(); + } + + /// Never leak a configured password into the probe's result message — it surfaces + /// directly in the AdminUI Test-connect UI. + [Fact] + public async Task ProbeAsync_NeverLeaksPasswordIntoMessage() + { + var listener = StartListener(); + var port = ListenerPort(listener); + listener.Stop(); + + const string secret = "S3cr3tSquirrel!!"; + var config = $$""" + {"host":"127.0.0.1","port":{{port}},"useTls":false,"username":"probe-user","password":"{{secret}}","connectTimeoutSeconds":2} + """; + + var r = await new MqttDriverProbe().ProbeAsync(config, QuickTimeout, CancellationToken.None); + + r.Ok.ShouldBeFalse(); + r.Message.ShouldNotBeNullOrEmpty(); + r.Message.ShouldNotContain(secret); + } + + /// Enum fields (protocolVersion) must round-trip by name, per the repo's documented + /// enum-serialization trap — a numeric-only seam faults AdminUI-authored configs. + [Fact] + public async Task ProbeAsync_EnumAsName_DoesNotThrow() + { + var listener = StartListener(); + var port = ListenerPort(listener); + listener.Stop(); + + var config = $$""" + {"host":"127.0.0.1","port":{{port}},"useTls":false,"protocolVersion":"V500","connectTimeoutSeconds":2} + """; + + var r = await new MqttDriverProbe().ProbeAsync(config, QuickTimeout, CancellationToken.None); + + r.Ok.ShouldBeFalse(); + r.Message.ShouldNotBeNullOrEmpty(); + } +} From 211c6ea6d6c989ef96a8e6bff54fb84059398284 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 16:48:55 -0400 Subject: [PATCH 17/43] feat(mqtt): register MqttDriverBrowser (bespoke-first for Mqtt) Wires the bespoke MqttDriverBrowser into AdminUI's IDriverBrowser registrations alongside OpcUaClient/Galaxy, overriding the universal DiscoveryDriverBrowser fallback for driverType "Mqtt". Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs | 2 ++ .../ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj | 1 + 2 files changed, 3 insertions(+) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs index 11d629c5..ac47e238 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs @@ -10,6 +10,7 @@ using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; using ZB.MOM.WW.OtOpcUa.Commons.Browsing; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser; +using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser; using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser; using ZB.MOM.WW.Secrets.Ui; @@ -74,6 +75,7 @@ public static class EndpointRouteBuilderExtensions services.AddScoped(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // Universal Discover-backed fallback browser (Wave 0). IDriverFactory is bound by the // fused Host's DriverFactoryBootstrap; a standalone AdminUI has none → NullDriverFactory // → CanBrowse false → pickers gracefully stay manual-entry (design §6). diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj index 10a58694..c9989d6c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj @@ -39,6 +39,7 @@ + From 420692b6e503bd4847c3986626fd51ff79d08c14 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 17:06:39 -0400 Subject: [PATCH 18/43] =?UTF-8?q?feat(mqtt):=20MqttDriver=20shell=20?= =?UTF-8?q?=E2=80=94=20lifecycle=20+=20authored-only=20discovery=20(Once)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composes MqttConnection + MqttSubscriptionManager + LastValueCache into the IDriver / ITagDiscovery / ISubscribable / IReadable / IHostConnectivityProbe / IRediscoverable capability set. - Discovery replays ONLY the authored raw tags (RediscoverPolicy = Once, SupportsOnlineDiscovery = false) — a chatty broker can never auto-provision. - Composition order is register -> AttachTo -> ConnectAsync; the manager's Reconnected handler is passed through unwrapped so its throw-on-total-failure still tears a deaf session down. - ReinitializeAsync applies a tag-only delta in place and never faults on a bad one; a session-changing delta rebuilds. - ReadAsync serves the last-value cache and degrades per reference (BadWaitingForInitialData), never throwing for the batch. - FlushOptionalCachesAsync is a no-op: the last-value cache IS IReadable. - Adds MqttDriverOptions.RawTags (the authored-tag delivery mechanism every other driver's options DTO already has) and promotes MaxPayloadBytes from a manager ctor knob to an operator-facing key. - Converges on MqttDriverProbe.JsonOpts rather than a second, divergent JsonSerializerOptions. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../MqttDriverOptions.cs | 36 + .../MqttDriver.cs | 732 ++++++++++++++++++ .../MqttDriverDiscoveryTests.cs | 305 ++++++++ 3 files changed, 1073 insertions(+) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttDriverOptions.cs index 47e93a45..8acb9148 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttDriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttDriverOptions.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.Text; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt; @@ -82,9 +83,40 @@ public sealed record MqttDriverOptions [Range(1, int.MaxValue)] public int ReconnectMaxBackoffSeconds { get; init; } = 30; + /// + /// Ceiling on an inbound message body, in bytes. A larger message is refused before any + /// decode or parse and degrades its own tags to BadDecodingError. Default 1 MiB. + /// + /// + /// Decode and parse both run synchronously on MQTTnet's shared dispatcher thread, so their + /// cost is paid by every subscription, not just the offending topic — without a bound, + /// one publisher shipping a multi-megabyte body stalls the whole driver's delivery. "Unbounded" + /// is deliberately not offered; a non-positive value falls back to the driver's own 1 MiB + /// default. The literal is duplicated from MqttSubscriptionManager.DefaultMaxPayloadBytes + /// because this .Contracts assembly is referenced by the driver assembly and + /// cannot reference it back; the manager's constructor is the single enforcement point. + /// + [Range(1, int.MaxValue)] + public int MaxPayloadBytes { get; init; } = 1024 * 1024; + /// Selects the ingest shape — Plain topics or Sparkplug B. public MqttMode Mode { get; init; } = MqttMode.Plain; + /// + /// The cluster's authored raw MQTT tags, as delivered by the deploy artifact: each carries the + /// tag's RawPath (its v3 identity and driver wire reference) plus its TagConfig + /// blob. maps each through at + /// initialize and re-registers the set wholesale on every reinitialize, so a redeploy + /// that drops a tag stops feeding it. + /// + /// + /// This is also the only source of the driver's discoverable node set — plain MQTT has + /// no browsable address space, and a tag that is not authored here is never materialized no + /// matter how much traffic its topic carries. Mirrors ModbusDriverOptions.RawTags / + /// FocasDriverOptions.RawTags. + /// + public IReadOnlyList RawTags { get; init; } = []; + /// /// Sparkplug-only settings. Populated when is /// ; null in Plain mode. @@ -127,9 +159,13 @@ public sealed record MqttDriverOptions builder.Append(", ConnectTimeoutSeconds = ").Append(ConnectTimeoutSeconds); builder.Append(", ReconnectMinBackoffSeconds = ").Append(ReconnectMinBackoffSeconds); builder.Append(", ReconnectMaxBackoffSeconds = ").Append(ReconnectMaxBackoffSeconds); + builder.Append(", MaxPayloadBytes = ").Append(MaxPayloadBytes); builder.Append(", Mode = ").Append(Mode); builder.Append(", Sparkplug = ").Append(Sparkplug); builder.Append(", Plain = ").Append(Plain); + // Count only: a deployment routinely authors thousands of raw tags and each carries a full + // TagConfig blob, so printing the list would turn any log of this DTO into a config dump. + builder.Append(", RawTags = ").Append(RawTags.Count).Append(" tag(s)"); return true; } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs new file mode 100644 index 00000000..5481f1cd --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs @@ -0,0 +1,732 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt; + +/// +/// The MQTT / Sparkplug B driver shell: composes (broker session + +/// reconnect supervisor), (authored table, topic routing, +/// value extraction) and the manager's (the push→poll bridge) into +/// the driver capability set the Core consumes. +/// +/// +/// +/// Authored-only discovery. Plain MQTT has no browsable address space — a broker will +/// happily carry thousands of topics this deployment has nothing to do with. +/// therefore replays exactly the raw tags the deploy artifact +/// authored () and nothing else, and +/// stays so the +/// universal browser never treats broker traffic as a tag catalogue. The policy is +/// : the authored set is fully known +/// synchronously, so re-running discovery on a timer can only produce the same tree. +/// +/// +/// P1 scope — no Sparkplug. is implemented but +/// never fires in : the +/// authored set only changes by redeploy. Sparkplug B flips the policy to +/// and raises the event on DBIRTH — that +/// is a later task, and the seam here () exists for it. +/// +/// +/// Composition order is load-bearing. +/// must run before any subscribe and before the connect completes — it is what wires +/// message delivery and, critically, the reconnect re-subscribe. The connection's +/// Reconnected handler is passed through unwrapped: the manager throws when it can +/// re-establish nothing, and that throw is precisely what tears the session down and retries. +/// Swallowing it would leave a driver that reports Connected and receives nothing. +/// +/// +/// Constructor is connection-free. It maps the authored TagConfig blobs (pure, no I/O) +/// so discovery and reads work off a configured-but-not-yet-connected driver; every network +/// operation happens in . +/// +/// +public sealed class MqttDriver + : IDriver, ITagDiscovery, ISubscribable, IReadable, IHostConnectivityProbe, IRediscoverable, IAsyncDisposable, IDisposable +{ + /// + /// Rough per-tag driver-attributable cost: the parsed (five + /// strings — RawPath, topic, JSONPath and the record header) plus its cached + /// and the two dictionary entries that index it. An estimate + /// by construction — GetMemoryFootprint exists to drive a cache-budget decision, not + /// to be exact. + /// + private const long ApproxBytesPerAuthoredTag = 512; + + /// How often the host-connectivity poll samples . + private static readonly TimeSpan HostProbeInterval = TimeSpan.FromSeconds(1); + + private readonly string _driverInstanceId; + private readonly ILogger? _logger; + + /// + /// Owned by the driver, not by the manager, so a manager rebuilt for changed ingest settings + /// inherits the observed values instead of silently regressing every node to + /// BadWaitingForInitialData. + /// + private readonly LastValueCache _values = new(); + + private readonly object _hostLock = new(); + + /// Guards / / against each other. + private readonly SemaphoreSlim _lifecycleGate = new(1, 1); + + private MqttDriverOptions _options; + private MqttSubscriptionManager _subscriptions; + + /// + /// RawPaths of the currently registered authored tags, in authoring order — the discovery + /// enumeration set. The manager resolves a RawPath to its definition but does not enumerate, + /// and enumerating from here is what makes "authored only" structural rather than incidental. + /// + private IReadOnlyList _authoredRawPaths = []; + + private MqttConnection? _connection; + private CancellationTokenSource? _hostProbeCts; + + private DriverHealth _health = new(DriverState.Unknown, null, null); + private HostState _hostState = HostState.Unknown; + private DateTime _hostStateChangedUtc = DateTime.UtcNow; + private int _disposed; + + /// Initializes a new driver. Stores + maps configuration only — no network, no client. + /// + /// Driver configuration, including the authored . + /// Task 9's factory deserializes it from the DriverConfig JSON; a later + /// may replace it from a fresh config blob. + /// + /// Stable logical id of this driver instance. + /// Optional logger; never receives credentials or payload bodies. + public MqttDriver(MqttDriverOptions options, string driverInstanceId, ILogger? logger = null) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); + + _options = options; + _driverInstanceId = driverInstanceId; + _logger = logger; + _subscriptions = CreateSubscriptionManager(options); + RegisterAuthoredTags(options); + } + + /// + public event EventHandler? OnDataChange; + + /// + public event EventHandler? OnHostStatusChanged; + + /// + public event EventHandler? OnRediscoveryNeeded; + + /// + public string DriverInstanceId => _driverInstanceId; + + /// + // Literal rather than a constant: DriverTypeNames.Mqtt does not exist yet — Task 9 adds it and + // owns that file. The string must stay EXACTLY "Mqtt" and match MqttDriverProbe.DriverType: a + // DriverType that drifts from the persisted one silently breaks dispatch (the ModbusTcp/Modbus + // incident). + public string DriverType => "Mqtt"; + + /// + /// The ingest path this driver composes. Internal: the P2 Sparkplug handler feeds the same + /// OnDataChange + sinks through it, and the shell tests + /// drive to simulate broker traffic + /// without a broker. + /// + internal MqttSubscriptionManager Subscriptions => _subscriptions; + + // ---- IDriver: lifecycle ---- + + /// + /// Adopts (falling back to the constructor's options when + /// it is blank or unusable), registers the authored tags, and opens the broker session. + /// + /// + /// Ordering is the contract: register → attach → connect. Attaching before the connect + /// completes means the reconnect re-subscribe is wired for the very first drop, and the + /// manager's SUBSCRIBE transport is live before the OPC UA server's first subscribe arrives. + /// + /// The driver configuration as JSON. + /// Cancellation for the connect. + /// A task that represents the asynchronous operation. + public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) + { + await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await InitializeCoreAsync(driverConfigJson, cancellationToken).ConfigureAwait(false); + } + finally + { + _lifecycleGate.Release(); + } + } + + /// + /// Applies a config delta in place. A tag-only delta re-registers the authored table without + /// touching the broker session; a delta that changes how the driver connects or ingests + /// rebuilds the session. + /// + /// + /// A bad delta never faults the driver. Unparseable or unusable config is logged and + /// discarded, leaving the running configuration exactly as it was — a redeploy carrying one + /// malformed driver blob must not take a healthy driver's whole address space Bad. A genuine + /// connect failure against a changed endpoint is a different thing and does fault, + /// exactly as would. + /// + /// The driver configuration as JSON. + /// Cancellation for any reconnect the delta forces. + /// A task that represents the asynchronous operation. + public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) + { + MqttDriverOptions? next; + try + { + next = ParseOptions(driverConfigJson); + } + catch (Exception ex) + { + // Defence in depth: ParseOptions already swallows the JSON failure modes. + _logger?.LogWarning( + ex, + "MQTT driver '{DriverId}': reinitialize config could not be read; keeping the running configuration.", + _driverInstanceId); + return; + } + + if (next is null) + { + _logger?.LogWarning( + "MQTT driver '{DriverId}': reinitialize config was blank or unusable; keeping the running configuration.", + _driverInstanceId); + return; + } + + await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (!SameSession(next, _options)) + { + // Broker endpoint / credentials / ingest shape changed — the live session cannot be + // reused. Full rebuild; a failure here is a real fault, not a bad delta. + _logger?.LogInformation( + "MQTT driver '{DriverId}': reinitialize changes the broker session; rebuilding.", + _driverInstanceId); + await TeardownAsync().ConfigureAwait(false); + await InitializeCoreAsync(driverConfigJson, cancellationToken).ConfigureAwait(false); + return; + } + + _options = next; + RegisterAuthoredTags(next); + _logger?.LogInformation( + "MQTT driver '{DriverId}': reinitialize applied in place; {TagCount} authored tag(s).", + _driverInstanceId, + _authoredRawPaths.Count); + } + finally + { + _lifecycleGate.Release(); + } + } + + /// + public async Task ShutdownAsync(CancellationToken cancellationToken) + { + var lastMessage = ReadHealth().LastSuccessfulRead; + if (await _lifecycleGate.WaitAsync(TimeSpan.FromSeconds(_options.ConnectTimeoutSeconds), cancellationToken) + .ConfigureAwait(false)) + { + try + { + await TeardownAsync().ConfigureAwait(false); + } + finally + { + _lifecycleGate.Release(); + } + } + else + { + // A wedged initialize must not turn shutdown into a hang; tear the session down anyway. + _logger?.LogWarning( + "MQTT driver '{DriverId}': lifecycle gate still held at shutdown; tearing the session down anyway.", + _driverInstanceId); + await TeardownAsync().ConfigureAwait(false); + } + + WriteHealth(new DriverHealth(DriverState.Unknown, lastMessage, null)); + TransitionHost(HostState.Unknown); + } + + /// + /// Current health. Derived live from the connection's own state where one exists, so a + /// background reconnect is visible without the driver having to mirror every transition. + /// LastSuccessfulRead carries the last inbound message instant — MQTT never polls, so + /// "last message age" is the only evidence the session is actually delivering. + /// + /// The driver's current health snapshot. + public DriverHealth GetHealth() + { + var stored = ReadHealth(); + var connection = _connection; + if (connection is null) + { + return stored; + } + + var lastMessage = connection.LastMessageUtc ?? stored.LastSuccessfulRead; + return connection.State switch + { + MqttConnectionState.Connected => new DriverHealth(DriverState.Healthy, lastMessage, stored.LastError), + MqttConnectionState.Reconnecting => new DriverHealth(DriverState.Reconnecting, lastMessage, stored.LastError), + MqttConnectionState.Faulted => new DriverHealth(DriverState.Faulted, lastMessage, stored.LastError), + // Disconnected before the first successful connect, or Disposed after teardown: the + // stored snapshot (Initializing / Faulted / Unknown) is the more informative answer. + _ => stored with { LastSuccessfulRead = lastMessage }, + }; + } + + /// + /// Approximate driver-attributable footprint: the authored definition table and the + /// last-value cache that indexes it. Both are sized by the authored tag count, which is the + /// only thing a deployment can grow. + /// + /// The approximate memory footprint in bytes. + public long GetMemoryFootprint() => _authoredRawPaths.Count * ApproxBytesPerAuthoredTag; + + /// + /// No-op, deliberately. This driver holds no optional cache: the authored table is the + /// address space itself, and the last-value cache is — MQTT is + /// subscribe-first, so a dropped value is not re-fetchable, and flushing it would turn every + /// node Bad until its topic next published, which for a slow-changing tag can be never. + /// Birth/alias state (Sparkplug, P2) is correctness state for the same reason. + /// + /// Unused; present for the shape. + /// A completed task. + public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + // ---- ITagDiscovery ---- + + /// + /// + /// Plain mode discovers synchronously from the authored set, so one pass is all there is. + /// Sparkplug B (P2) fills its metric set in asynchronously from birth certificates and + /// switches this to . + /// + public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once; + + /// + /// + /// Always : replays authored tags, it does + /// not enumerate a backend. A broker's live topic set is not a tag catalogue — the + /// Driver.Mqtt.Browser project is the surface for observing traffic during authoring. + /// + public bool SupportsOnlineDiscovery => false; + + /// + /// Streams the authored tag set — and only that — into . + /// + /// + /// The enumeration source is the driver's own list of registered RawPaths, never anything + /// observed on the wire, so no amount of broker traffic can add a node. A RawPath whose + /// TagConfig failed to map is absent from the manager and is skipped here too — it surfaces + /// as BadNodeIdUnknown on read rather than as a wrongly-typed variable. + /// + /// The address space builder to stream discovered nodes into. + /// Cancellation for the discovery operation. + /// A task that represents the asynchronous operation. + public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(builder); + cancellationToken.ThrowIfCancellationRequested(); + + var folder = builder.Folder("Mqtt", "Mqtt"); + foreach (var rawPath in _authoredRawPaths) + { + if (!_subscriptions.TryResolve(rawPath, out var def)) + { + continue; + } + + folder.Variable(def.Name, def.Name, new DriverAttributeInfo( + FullName: def.Name, + DriverDataType: def.DataType, + IsArray: false, + ArrayDim: null, + // MQTT ingest is one-way in P1 — this driver implements no IWritable leg, so every + // node is ViewOnly rather than an Operate node whose writes would silently vanish. + SecurityClass: SecurityClassification.ViewOnly, + IsHistorized: false, + IsAlarm: false, + WriteIdempotent: false)); + } + + return Task.CompletedTask; + } + + // ---- ISubscribable (straight through to the manager) ---- + + /// + public Task SubscribeAsync( + IReadOnlyList fullReferences, + TimeSpan publishingInterval, + CancellationToken cancellationToken) + => _subscriptions.SubscribeAsync(fullReferences, publishingInterval, cancellationToken); + + /// + public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) + => _subscriptions.UnsubscribeAsync(handle, cancellationToken); + + // ---- IReadable ---- + + /// + /// Serves the batch from the last-value cache. MQTT is push-based: there is nothing to poll, + /// so a read is always "what was most recently published". + /// + /// + /// Never throws. A reference that has not been observed — including one that is not an + /// authored tag at all — returns BadWaitingForInitialData in its own slot. Failing the + /// whole call would let one stale reference blank every other node in the same OPC UA read. + /// + /// The RawPath references to read. + /// Unused; the read touches no network. + /// One snapshot per requested reference, in request order. + public Task> ReadAsync( + IReadOnlyList fullReferences, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(fullReferences); + + var results = new DataValueSnapshot[fullReferences.Count]; + for (var i = 0; i < fullReferences.Count; i++) + { + results[i] = _subscriptions.Values.Read(fullReferences[i]); + } + + return Task.FromResult>(results); + } + + // ---- IHostConnectivityProbe ---- + + /// The single broker this driver instance talks to, as host:port. + public string HostName => $"{_options.Host}:{_options.Port}"; + + /// + public IReadOnlyList GetHostStatuses() + { + lock (_hostLock) + { + return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)]; + } + } + + // ---- IRediscoverable ---- + + /// + /// Raises . Nothing in calls it + /// — the authored set only changes by redeploy. It exists for the Sparkplug B path, where a + /// DBIRTH can introduce metrics the previous birth certificate did not carry. + /// + /// Driver-supplied reason for the diagnostic log. + /// Optional subtree hint; means the whole tree. + internal void RaiseRediscoveryNeeded(string reason, string? scopeHint = null) + => OnRediscoveryNeeded?.Invoke(this, new RediscoveryEventArgs(reason, scopeHint)); + + // ---- disposal ---- + + /// + public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult(); + + /// + /// Performs the same teardown as so a caller that uses + /// await using without an explicit shutdown does not leak a live broker session. + /// + /// A task that represents the asynchronous dispose. + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + // The lifecycle gate is deliberately NOT disposed — same call as MqttConnection's semaphores. + // A caller that disposes before its last ShutdownAsync would otherwise get an + // ObjectDisposedException naming SemaphoreSlim, which says nothing useful; the gate holds no + // timer and no surviving registration, so leaving it undisposed costs nothing. + await TeardownAsync().ConfigureAwait(false); + } + + // ---- internals ---- + + /// The gate-held body of . + private async Task InitializeCoreAsync(string driverConfigJson, CancellationToken cancellationToken) + { + WriteHealth(new DriverHealth(DriverState.Initializing, ReadHealth().LastSuccessfulRead, null)); + try + { + // A blank / unusable blob keeps the constructor's options rather than degrading the + // driver to defaults pointing at localhost. + var parsed = ParseOptions(driverConfigJson); + if (parsed is not null) + { + AdoptOptions(parsed); + } + + RegisterAuthoredTags(_options); + + var connection = new MqttConnection(_options, _driverInstanceId, _logger); + + // MUST precede the connect: this wires message delivery AND the reconnect re-subscribe. + // The manager's Reconnected handler is passed through unwrapped on purpose — its + // throw-on-total-failure is what tears a deaf session down and retries it. + _subscriptions.AttachTo(connection); + + await connection.ConnectAsync(cancellationToken).ConfigureAwait(false); + _connection = connection; + + WriteHealth(new DriverHealth(DriverState.Healthy, connection.LastMessageUtc, null)); + TransitionHost(HostState.Running); + StartHostProbe(); + } + catch (Exception ex) + { + WriteHealth(new DriverHealth(DriverState.Faulted, ReadHealth().LastSuccessfulRead, ex.Message)); + TransitionHost(HostState.Faulted); + throw; + } + } + + /// + /// Deserializes a driver-config blob. Returns — never throws — when the + /// blob is blank or unusable, so both the initialize fallback and the reinitialize + /// keep-running-config rule read off one answer. + /// + private MqttDriverOptions? ParseOptions(string driverConfigJson) + { + if (string.IsNullOrWhiteSpace(driverConfigJson)) + { + return null; + } + + try + { + // The probe's shared instance, deliberately: enums must round-trip by NAME. A second, + // divergent JsonSerializerOptions is this repo's documented systemic enum bug, where an + // AdminUI-authored config with a numeric enum field faults the driver. + return JsonSerializer.Deserialize(driverConfigJson, MqttDriverProbe.JsonOpts); + } + catch (JsonException ex) + { + _logger?.LogWarning( + ex, + "MQTT driver '{DriverId}': driver config JSON is invalid.", + _driverInstanceId); + return null; + } + catch (NotSupportedException ex) + { + _logger?.LogWarning( + ex, + "MQTT driver '{DriverId}': driver config JSON could not be bound to the options shape.", + _driverInstanceId); + return null; + } + } + + /// + /// Adopts new options, rebuilding the subscription manager only when a setting it captures at + /// construction actually changed. The rebuilt manager inherits the driver-owned + /// , so adopting new options never blanks observed values. + /// + private void AdoptOptions(MqttDriverOptions next) + { + if (!SameIngest(next, _options)) + { + _subscriptions = CreateSubscriptionManager(next); + } + + _options = next; + } + + /// Builds a manager over the driver-owned cache and bridges its notifications to . + private MqttSubscriptionManager CreateSubscriptionManager(MqttDriverOptions options) + { + var manager = new MqttSubscriptionManager( + options, + _driverInstanceId, + transport: null, + logger: _logger, + cache: _values, + maxPayloadBytes: options.MaxPayloadBytes); + + // The published reference is the RawPath the manager already stamped on the args; the driver + // only re-raises with itself as sender. + manager.OnDataChange += (_, args) => OnDataChange?.Invoke(this, args); + return manager; + } + + /// Registers the authored raw tags wholesale and refreshes the discovery enumeration set. + private void RegisterAuthoredTags(MqttDriverOptions options) + { + var mapped = _subscriptions.Register(options.RawTags); + + // Enumerate in authoring order; the manager owns which of these actually mapped, and + // DiscoverAsync skips the rest. + _authoredRawPaths = [.. options.RawTags.Select(t => t.RawPath)]; + + if (mapped != options.RawTags.Count) + { + _logger?.LogWarning( + "MQTT driver '{DriverId}': {Mapped} of {Authored} authored tag(s) mapped to a definition; " + + "the rest will report BadNodeIdUnknown.", + _driverInstanceId, + mapped, + options.RawTags.Count); + } + } + + /// + /// Whether two option sets describe the same broker session. Note the explicit + /// : the identity projections are object-typed + /// anonymous instances, so != would compare references and report "changed" + /// every single time — which would tear down and rebuild a perfectly good session on every + /// redeploy, and (via ) swap in a subscription manager that is not + /// attached to the live connection. + /// + private static bool SameSession(MqttDriverOptions a, MqttDriverOptions b) + => SessionIdentity(a).Equals(SessionIdentity(b)); + + /// Whether two option sets produce an identical . + private static bool SameIngest(MqttDriverOptions a, MqttDriverOptions b) + => IngestIdentity(a).Equals(IngestIdentity(b)); + + /// + /// The settings whose change invalidates the live broker session. Projected onto an anonymous + /// type (structural Equals) rather than comparing the whole options record, whose + /// RawTags list would make every comparison unequal by reference. + /// + private static object SessionIdentity(MqttDriverOptions o) => new + { + o.Host, + o.Port, + o.ClientId, + o.UseTls, + o.AllowUntrustedServerCertificate, + o.CaCertificatePath, + o.Username, + o.Password, + o.ProtocolVersion, + o.CleanSession, + o.KeepAliveSeconds, + o.ConnectTimeoutSeconds, + o.ReconnectMinBackoffSeconds, + o.ReconnectMaxBackoffSeconds, + Ingest = IngestIdentity(o), + }; + + /// The settings captures at construction. + private static object IngestIdentity(MqttDriverOptions o) => new + { + o.Mode, + DefaultQos = o.Plain?.DefaultQos, + o.MaxPayloadBytes, + }; + + /// Shared teardown for , and the session rebuild. + private async Task TeardownAsync() + { + var probe = Interlocked.Exchange(ref _hostProbeCts, null); + if (probe is not null) + { + await probe.CancelAsync().ConfigureAwait(false); + probe.Dispose(); + } + + var connection = Interlocked.Exchange(ref _connection, null); + if (connection is not null) + { + await connection.DisposeAsync().ConfigureAwait(false); + } + } + + /// + /// Starts the host-connectivity poll. exposes state but no + /// state-changed event, and an whose event never fires is + /// a capability that looks wired and is inert — so the transition signal is sampled here. + /// + private void StartHostProbe() + { + var cts = new CancellationTokenSource(); + var previous = Interlocked.Exchange(ref _hostProbeCts, cts); + if (previous is not null) + { + // Cancel before disposing: disposing a live source leaves its loop running against a + // token that will never be cancelled, and a second probe would fight the first. + previous.Cancel(); + previous.Dispose(); + } + + _ = Task.Run(() => HostProbeLoopAsync(cts.Token), cts.Token); + } + + private async Task HostProbeLoopAsync(CancellationToken ct) + { + try + { + while (!ct.IsCancellationRequested) + { + await Task.Delay(HostProbeInterval, ct).ConfigureAwait(false); + + var connection = _connection; + if (connection is null) + { + continue; + } + + TransitionHost(connection.State switch + { + MqttConnectionState.Connected => HostState.Running, + MqttConnectionState.Reconnecting or MqttConnectionState.Disconnected => HostState.Stopped, + MqttConnectionState.Faulted => HostState.Faulted, + _ => HostState.Unknown, + }); + } + } + catch (OperationCanceledException) + { + // Shutdown. + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "MQTT driver '{DriverId}': host connectivity probe stopped.", _driverInstanceId); + } + } + + /// Publishes a host state transition once; repeats of the current state are ignored. + private void TransitionHost(HostState next) + { + HostState previous; + lock (_hostLock) + { + if (_hostState == next) + { + return; + } + + previous = _hostState; + _hostState = next; + _hostStateChangedUtc = DateTime.UtcNow; + } + + OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, previous, next)); + } + + /// Barrier-protected read of the multi-threaded health field. + private DriverHealth ReadHealth() => Volatile.Read(ref _health); + + /// Barrier-protected publish of a new health snapshot. + private void WriteHealth(DriverHealth value) => Volatile.Write(ref _health, value); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs new file mode 100644 index 00000000..70a92223 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs @@ -0,0 +1,305 @@ +using System.Text; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests; + +/// +/// Shell-level tests for : authored-only discovery, the lifecycle +/// surface (Reinitialize / Shutdown / health / footprint / cache flush), and the +/// IReadable batch contract. Nothing here dials a broker — every test exercises the +/// connection-free half of the driver, which is exactly the half a chatty broker must not be +/// able to influence. +/// +[Trait("Category", "Unit")] +public sealed class MqttDriverDiscoveryTests +{ + /// + /// An authored TagConfig blob. dataType uses the real + /// member names — there is no Double; the 64-bit float is Float64. + /// + private static string TagJson(string topic, string dataType = "String", string payloadFormat = "Raw") + => $$"""{"topic":"{{topic}}","payloadFormat":"{{payloadFormat}}","dataType":"{{dataType}}"}"""; + + private static RawTagEntry Tag(string rawPath, string topic, string dataType = "String") + => new(rawPath, TagJson(topic, dataType), WriteIdempotent: false); + + private static MqttDriver PlainDriver(params RawTagEntry[] tags) + => new(new MqttDriverOptions { Mode = MqttMode.Plain, RawTags = tags }, "d", null); + + /// + /// Plain mode replays ONLY the authored tag set: one variable per authored raw tag, a + /// single discovery pass (), and no online + /// discovery — the universal browser must never treat a broker's topic traffic as a + /// browsable address space. + /// + [Fact] + public async Task DiscoverAsync_Plain_StreamsAuthoredTagsOnly_PolicyOnce() + { + var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t")); + var b = new CapturingAddressSpaceBuilder(); + + await driver.DiscoverAsync(b, CancellationToken.None); + + b.Variables.Count.ShouldBe(1); + b.Variables[0].Info.FullName.ShouldBe("Plant/Mqtt/dev1/Temp"); + driver.RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once); + ((ITagDiscovery)driver).SupportsOnlineDiscovery.ShouldBeFalse(); + } + + /// + /// The falsifiability pin for authored-only discovery: a message that arrives on a topic no + /// authored tag names must not add anything to the discovered set. A driver that + /// auto-provisioned from broker traffic would grow the address space on every deploy against + /// a chatty broker. + /// + [Fact] + public async Task DiscoverAsync_UnauthoredBrokerTraffic_DoesNotAppear() + { + var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t")); + + // Traffic this driver never authored — a neighbouring publisher on the same broker. + driver.Subscriptions.HandleMessage("some/other/topic", Encoding.UTF8.GetBytes("42"), retained: false); + driver.Subscriptions.HandleMessage("f/t/deeper", Encoding.UTF8.GetBytes("42"), retained: false); + + var b = new CapturingAddressSpaceBuilder(); + await driver.DiscoverAsync(b, CancellationToken.None); + + b.Variables.Select(v => v.Info.FullName).ShouldBe(["Plant/Mqtt/dev1/Temp"]); + } + + /// A raw tag whose TagConfig does not map is skipped, never thrown, and never discovered. + [Fact] + public async Task DiscoverAsync_SkipsUnmappableTagConfig() + { + var driver = PlainDriver( + Tag("Plant/Mqtt/dev1/Good", "f/t"), + new RawTagEntry("Plant/Mqtt/dev1/Bad", "not-json", WriteIdempotent: false)); + + var b = new CapturingAddressSpaceBuilder(); + await driver.DiscoverAsync(b, CancellationToken.None); + + b.Variables.Select(v => v.Info.FullName).ShouldBe(["Plant/Mqtt/dev1/Good"]); + } + + /// Discovered MQTT variables are read-only — this driver has no IWritable leg. + [Fact] + public async Task DiscoverAsync_MarksVariablesViewOnly() + { + var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t", "Float64")); + var b = new CapturingAddressSpaceBuilder(); + + await driver.DiscoverAsync(b, CancellationToken.None); + + b.Variables[0].Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly); + b.Variables[0].Info.DriverDataType.ShouldBe(DriverDataType.Float64); + } + + /// + /// The falsifiability pin for the batch-read contract: a reference that is not an authored + /// tag degrades to BadWaitingForInitialData in its own slot rather than throwing and + /// failing every other reference in the same batch. + /// + [Fact] + public async Task ReadAsync_UnknownReference_DoesNotThrow_AndDegradesPerRef() + { + var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t")); + driver.Subscriptions.HandleMessage("f/t", Encoding.UTF8.GetBytes("hot"), retained: false); + + var results = await driver.ReadAsync( + ["Plant/Mqtt/dev1/Temp", "Plant/Mqtt/dev1/NeverAuthored"], + CancellationToken.None); + + results.Count.ShouldBe(2); + results[0].StatusCode.ShouldBe(0u); + results[0].Value.ShouldBe("hot"); + results[1].StatusCode.ShouldBe(0x80320000u); // BadWaitingForInitialData + } + + /// An empty batch is legal and returns an empty result, not an exception. + [Fact] + public async Task ReadAsync_EmptyBatch_ReturnsEmpty() + { + var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t")); + + var results = await driver.ReadAsync([], CancellationToken.None); + + results.ShouldBeEmpty(); + } + + /// + /// The falsifiability pin for the cache-flush contract: the last-value cache backs + /// IReadable, so flushing it would silently turn every subscribed node Bad under + /// memory pressure. FlushOptionalCachesAsync must leave it untouched. + /// + [Fact] + public async Task FlushOptionalCachesAsync_LeavesLastValueCacheIntact() + { + var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t")); + driver.Subscriptions.HandleMessage("f/t", Encoding.UTF8.GetBytes("hot"), retained: false); + + await driver.FlushOptionalCachesAsync(CancellationToken.None); + + var results = await driver.ReadAsync(["Plant/Mqtt/dev1/Temp"], CancellationToken.None); + results[0].StatusCode.ShouldBe(0u); + results[0].Value.ShouldBe("hot"); + } + + /// A malformed reinitialize delta must never fault the driver, and must not lose the running config. + [Fact] + public async Task ReinitializeAsync_MalformedDelta_KeepsRunningConfig_AndDoesNotFault() + { + var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t")); + + await Should.NotThrowAsync(() => driver.ReinitializeAsync("{ this is not json", CancellationToken.None)); + + driver.GetHealth().State.ShouldNotBe(DriverState.Faulted); + + var b = new CapturingAddressSpaceBuilder(); + await driver.DiscoverAsync(b, CancellationToken.None); + b.Variables.Select(v => v.Info.FullName).ShouldBe(["Plant/Mqtt/dev1/Temp"]); + } + + /// + /// A delta that changes only the authored tag set is applied in place — no teardown, no + /// reconnect — and is applied WHOLESALE: a tag the redeploy dropped stops being discovered. + /// + [Fact] + public async Task ReinitializeAsync_TagOnlyDelta_AppliedInPlace_Wholesale() + { + var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t")); + + var pressure = System.Text.Json.JsonSerializer.Serialize(TagJson("f/p")); + var flow = System.Text.Json.JsonSerializer.Serialize(TagJson("f/q")); + var delta = $$""" + { + "mode": "Plain", + "rawTags": [ + { "rawPath": "Plant/Mqtt/dev1/Pressure", "tagConfig": {{pressure}}, "writeIdempotent": false }, + { "rawPath": "Plant/Mqtt/dev1/Flow", "tagConfig": {{flow}}, "writeIdempotent": false } + ] + } + """; + + await driver.ReinitializeAsync(delta, CancellationToken.None); + + var b = new CapturingAddressSpaceBuilder(); + await driver.DiscoverAsync(b, CancellationToken.None); + + b.Variables.Select(v => v.Info.FullName) + .OrderBy(x => x, StringComparer.Ordinal) + .ShouldBe(["Plant/Mqtt/dev1/Flow", "Plant/Mqtt/dev1/Pressure"]); + } + + /// Identity is fixed at construction and must match the persisted DriverInstance.DriverType. + [Fact] + public void Identity_IsMqtt() + { + var driver = PlainDriver(); + + driver.DriverType.ShouldBe("Mqtt"); + driver.DriverInstanceId.ShouldBe("d"); + } + + /// A driver that has never been initialized reports Unknown, not Healthy. + [Fact] + public void GetHealth_BeforeInitialize_IsUnknown() + { + PlainDriver().GetHealth().State.ShouldBe(DriverState.Unknown); + } + + /// Plain mode never signals rediscovery — the authored set only changes by redeploy. + [Fact] + public async Task Plain_NeverRaisesRediscoveryNeeded() + { + var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t")); + var fired = 0; + ((IRediscoverable)driver).OnRediscoveryNeeded += (_, _) => Interlocked.Increment(ref fired); + + driver.Subscriptions.HandleMessage("f/t", Encoding.UTF8.GetBytes("hot"), retained: false); + await driver.DiscoverAsync(new CapturingAddressSpaceBuilder(), CancellationToken.None); + + fired.ShouldBe(0); + } + + /// The footprint tracks the authored table; an empty driver reports no driver-attributable bytes. + [Fact] + public void GetMemoryFootprint_TracksAuthoredTable() + { + PlainDriver().GetMemoryFootprint().ShouldBe(0); + PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t")).GetMemoryFootprint().ShouldBeGreaterThan(0); + } + + /// Shutdown on a never-initialized driver is a no-op, not a null-reference. + [Fact] + public async Task ShutdownAsync_BeforeInitialize_DoesNotThrow() + { + var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t")); + + await Should.NotThrowAsync(() => driver.ShutdownAsync(CancellationToken.None)); + driver.GetHealth().State.ShouldBe(DriverState.Unknown); + } + + /// The single-broker connectivity probe names the configured endpoint. + [Fact] + public void GetHostStatuses_ReportsTheConfiguredBroker() + { + var driver = new MqttDriver( + new MqttDriverOptions { Mode = MqttMode.Plain, Host = "broker.example", Port = 1883 }, + "d", + null); + + var statuses = ((IHostConnectivityProbe)driver).GetHostStatuses(); + + statuses.Count.ShouldBe(1); + statuses[0].HostName.ShouldBe("broker.example:1883"); + statuses[0].State.ShouldBe(HostState.Unknown); + } + + // ---- test doubles ---- + + /// + /// Records the streamed discovery tree instead of materializing OPC UA nodes. Local to this + /// suite: the driver test project does not reference Commons, where the runtime's own + /// capturing builder lives. Mirrors the per-driver RecordingBuilder the AB CIP / FOCAS + /// suites use. + /// + private sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder + { + /// Every folder streamed, in order, across the whole tree. + public List<(string BrowseName, string DisplayName)> Folders { get; } = []; + + /// Every variable streamed, in order, across the whole tree. + public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = []; + + /// + public IAddressSpaceBuilder Folder(string browseName, string displayName) + { + Folders.Add((browseName, displayName)); + return this; + } + + /// + public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) + { + Variables.Add((browseName, attributeInfo)); + return new Handle(attributeInfo.FullName); + } + + /// + public void AddProperty(string browseName, DriverDataType dataType, object? value) { } + + private sealed class Handle(string fullRef) : IVariableHandle + { + public string FullReference => fullRef; + + public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink(); + } + + private sealed class NullSink : IAlarmConditionSink + { + public void OnTransition(AlarmEventArgs args) { } + } + } +} From 36abd871a16f6c5701fca1b04db71cf6c913a772 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 17:17:37 -0400 Subject: [PATCH 19/43] feat(mqtt): typed tag editor + validator (plain mode) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thin typed model over a preserved JsonObject key bag, mirroring the sibling TagConfigModel template. Key names and strictness rules mirror MqttTagDefinitionFactory rather than inventing an editor-side schema; enums round-trip as NAMES (the factory reads them strictly by name). No FullName key: under v3 a tag is identified by its RawPath, which the factory keys the definition's Name off — a composed identity key here would be dead weight nothing reads. Mode is a UI-only sub-shape selector inferred from the blob (any Sparkplug descriptor key present) and never serialised — the driver takes Plain vs SparkplugB from its DRIVER config. Only Plain Validate() is implemented; the Sparkplug branch is a stub Task 24 fills, and its keys are preserved untouched. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../Uns/TagEditors/MqttTagConfigEditor.razor | 141 ++++++++++ .../Uns/TagEditors/MqttTagConfigModel.cs | 216 +++++++++++++++ .../Uns/TagEditors/TagConfigEditorMap.cs | 1 + .../Uns/TagEditors/TagConfigValidator.cs | 1 + .../Uns/MqttTagConfigModelTests.cs | 254 ++++++++++++++++++ 5 files changed, 613 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/MqttTagConfigEditor.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MqttTagConfigModel.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MqttTagConfigModelTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/MqttTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/MqttTagConfigEditor.razor new file mode 100644 index 00000000..810fe331 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/MqttTagConfigEditor.razor @@ -0,0 +1,141 @@ +@* Typed TagConfig editor for the Mqtt driver. Same (ConfigJson/ConfigJsonChanged/DriverType/ + GetDriverConfigJson) parameter shape every typed editor takes; the last two are accepted for + dispatch uniformity and unused here (MQTT has no address-builder picker — topics are discovered + through the /raw browse tree, not composed from parts). + + P1 authors Plain-mode tags only. The shape switch is real (it drives which field group renders and + what Validate() applies) but it is a UI-only choice derived from the blob, never serialised — the + driver takes Plain vs SparkplugB from its DRIVER config, and the tag blob has no 'mode' key. + Task 24 replaces the Sparkplug placeholder below with the real descriptor field group. *@ +@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors +@using ZB.MOM.WW.OtOpcUa.Core.Abstractions +@using ZB.MOM.WW.OtOpcUa.Driver.Mqtt + +
+
+ + +
+ + @if (_m.Mode == MqttMode.Plain) + { +
+ + +
Concrete topic — MQTT wildcards (+ / #) are not valid for a tag.
+
+
+ + +
+ @if (_m.PayloadFormat == MqttPayloadFormat.Json) + { +
+ + +
Use $ for the whole document.
+
+ } +
+ + @* The driver's own DriverDataType members — authoring anything outside this set produces a + blob the driver's strict parser rejects at deploy (there is no 'Double'; it is Float64). *@ + +
+
+ + +
+
+ + +
+ } + else + { +
+ +
+ } + + @if (_validationError is not null) + { +
@_validationError
+ } +
+ +@code { + /// The tag's TagConfig JSON, owned by the host modal. + [Parameter] public string? ConfigJson { get; set; } + /// Raised with the re-serialised TagConfig JSON after every field edit. + [Parameter] public EventCallback ConfigJsonChanged { get; set; } + /// DriverType of the owning driver — accepted for dispatch uniformity; unused here. + [Parameter] public string DriverType { get; set; } = ""; + /// Live accessor for the owning driver's DriverConfig JSON — accepted for dispatch uniformity; unused here. + [Parameter] public Func GetDriverConfigJson { get; set; } = () => "{}"; + + private MqttTagConfigModel _m = new(); + private string? _lastConfigJson; + private string? _validationError; + + // Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render + // (Blazor Server live-status pushes do this) can't reset the user's in-progress edits. + protected override void OnParametersSet() + { + if (ConfigJson == _lastConfigJson) { return; } + _lastConfigJson = ConfigJson; + _m = MqttTagConfigModel.FromJson(ConfigJson); + _validationError = _m.Validate(); + } + + // TryParse so a bad/empty change value can never throw into the Blazor circuit — it falls back. + private static TEnum ParseEnum(object? v, TEnum fallback) where TEnum : struct, Enum + => Enum.TryParse(v?.ToString(), out var r) ? r : fallback; + + // "" ⇒ null ⇒ the key is omitted and the driver's own default applies. + private static int? ParseNullableInt(object? v) + => int.TryParse(v?.ToString(), out var i) ? i : null; + + private static bool? ParseNullableBool(object? v) + => bool.TryParse(v?.ToString(), out var b) ? b : null; + + private async Task Update(Action apply) + { + apply(); + _validationError = _m.Validate(); + var json = _m.ToJson(); + // Keep the guard in OnParametersSet in step with what we just emitted, so the host echoing the + // new JSON back as a parameter cannot re-parse and clobber an in-progress edit. + _lastConfigJson = json; + await ConfigJsonChanged.InvokeAsync(json); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MqttTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MqttTagConfigModel.cs new file mode 100644 index 00000000..6d52fbac --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MqttTagConfigModel.cs @@ -0,0 +1,216 @@ +using System.Text.Json.Nodes; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Mqtt; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; + +/// +/// Typed working model for an MQTT tag's TagConfig JSON — the driver-specific binding fields +/// (name / access level / writability live on the Tag entity). Preserves unrecognised JSON keys +/// across a load→save. +/// +/// +/// +/// The authoritative consumer of the produced blob is +/// MqttTagDefinitionFactory.FromTagConfig (Driver.Mqtt.Contracts); every key name and +/// strictness rule here mirrors that factory rather than inventing an editor-side schema. +/// +/// +/// There is deliberately no FullName (or any other identity) key. Under the v3 +/// identity contract a tag is identified by its RawPath — the factory keys the produced +/// definition's Name off the RawPath it is handed, and the TagConfig is a pure address blob. +/// Writing a composed identity key here would be dead weight nothing reads. +/// +/// +/// is a UI-only sub-shape selector, inferred from the blob (the presence +/// of any Sparkplug descriptor key) and never serialised — the driver takes its +/// Plain/SparkplugB mode from the driver config, not from a tag, so +/// persisting a per-tag mode key would add a field the contract does not define. Only the +/// Plain rules are implemented in P1; the Sparkplug branch is a stub Task 24 fills. +/// +/// +public sealed class MqttTagConfigModel +{ + private const string TopicKey = "topic"; + private const string PayloadFormatKey = "payloadFormat"; + private const string JsonPathKey = "jsonPath"; + private const string DataTypeKey = "dataType"; + private const string QosKey = "qos"; + private const string RetainSeedKey = "retainSeed"; + + /// The Sparkplug B descriptor keys — authored by Task 24, preserved (never dropped) in P1. + private static readonly string[] SparkplugKeys = ["groupId", "edgeNodeId", "deviceId", "metricName"]; + + /// The MQTT wildcard characters; a tag's subscription topic must be concrete. + private static readonly char[] TopicWildcards = ['+', '#']; + + /// + /// Which ingest shape this tag is authored under — inferred from the blob, never serialised. + /// P1 authors only. + /// + public MqttMode Mode { get; set; } = MqttMode.Plain; + + /// The concrete MQTT topic the tag subscribes to (Plain mode). Required; no wildcards. + public string Topic { get; set; } = ""; + + /// How the received payload is decoded (Plain mode). + public MqttPayloadFormat PayloadFormat { get; set; } = MqttPayloadFormat.Json; + + /// + /// JSONPath selecting the value inside a payload. Blank ⇒ + /// the key is omitted and the driver applies the document root ($). + /// + public string JsonPath { get; set; } = ""; + + /// The tag's declared value type (the driver's set). + public DriverDataType DataType { get; set; } = DriverDataType.String; + + /// + /// Per-tag subscription QoS (0–2), or null to omit the key and inherit the driver-level + /// default. An absent key stays absent through a load→save. + /// + public int? Qos { get; set; } + + /// + /// Whether the broker's retained message seeds the tag's initial value, or null to omit + /// the key and inherit the driver's default (true). An absent key stays absent. + /// + public bool? RetainSeed { get; set; } + + private JsonObject _bag = new(); + + /// + /// Loads a model from a TagConfig JSON string, defaulting any absent field and retaining every + /// original key (so fields this editor doesn't expose — history intent, array/alarm objects, and + /// the Sparkplug descriptor keys — survive a load→save). + /// + /// The raw TagConfig JSON string, or null for a new/empty config. + /// The populated . + public static MqttTagConfigModel FromJson(string? json) + { + var o = TagConfigJson.ParseOrNew(json); + return new MqttTagConfigModel + { + Mode = InferMode(o), + Topic = TagConfigJson.GetString(o, TopicKey) ?? "", + PayloadFormat = TagConfigJson.GetEnum(o, PayloadFormatKey, MqttPayloadFormat.Json), + JsonPath = TagConfigJson.GetString(o, JsonPathKey) ?? "", + DataType = TagConfigJson.GetEnum(o, DataTypeKey, DriverDataType.String), + Qos = GetIntNullable(o, QosKey), + RetainSeed = TagConfigJson.GetBoolNullable(o, RetainSeedKey), + _bag = o, + }; + } + + /// + /// Serialises this model back to a TagConfig JSON string over the preserved key bag. Enums are + /// written as their names (the driver reads them strictly by name; an ordinal would be + /// rejected outright), and blank/null optionals are written as an absent key so the driver's own + /// defaults apply. + /// + /// The serialised TagConfig JSON string. + public string ToJson() + { + TagConfigJson.Set(_bag, TopicKey, Topic.Trim()); + TagConfigJson.Set(_bag, PayloadFormatKey, PayloadFormat); + TagConfigJson.Set(_bag, DataTypeKey, DataType); + TagConfigJson.Set(_bag, JsonPathKey, string.IsNullOrWhiteSpace(JsonPath) ? null : JsonPath.Trim()); + TagConfigJson.Set(_bag, QosKey, Qos); + TagConfigJson.Set(_bag, RetainSeedKey, RetainSeed); + return TagConfigJson.Serialize(_bag); + } + + /// + /// Client-side validation run by before a tag is saved. + /// Returns the first error, or null when the config is valid. + /// + /// + /// Two rules are deliberately stricter than the runtime parser, because this is an + /// authoring surface and the cheapest place to stop the mistake: a wildcard topic (the runtime + /// accepts it and Inspect only warns at deploy) and a Json payload with no + /// jsonPath (the runtime defaults to the document root). Everything else — required + /// topic, strict enums, QoS 0–2 — matches MqttTagDefinitionFactory exactly. The strict + /// enum/QoS checks read the ORIGINAL key bag, not the defaulted typed fields, so a blob that + /// never went through this editor (e.g. a CSV import) cannot pass validation while the driver + /// would reject it. + /// + /// An error message describing the validation failure, or null when valid. + public string? Validate() + { + // P1 stub: Sparkplug-shaped tags are not authored yet — Task 24 fills these rules. Applying the + // Plain rules to them would reject every Sparkplug tag for "missing topic". + if (Mode == MqttMode.SparkplugB) { return null; } + + if (DescribeInvalidEnum(_bag, PayloadFormatKey) is { } pfError) { return pfError; } + if (DescribeInvalidEnum(_bag, DataTypeKey) is { } dtError) { return dtError; } + if (DescribeInvalidQos(_bag) is { } qosError) { return qosError; } + + var topic = Topic.Trim(); + if (string.IsNullOrEmpty(topic)) { return "A topic is required."; } + if (topic.IndexOfAny(TopicWildcards) >= 0) + { + return $"Topic '{topic}' contains an MQTT wildcard (+ or #); a tag's topic must be concrete, " + + "or the tag would be fed by every matching topic."; + } + + if (PayloadFormat == MqttPayloadFormat.Json && string.IsNullOrWhiteSpace(JsonPath)) + { + return "A jsonPath is required when the payload format is Json (use \"$\" for the whole document)."; + } + + return null; + } + + /// + /// Infers the editor's sub-shape from the blob: any non-blank Sparkplug descriptor key marks a + /// Sparkplug tag, otherwise Plain. + /// + /// The parsed TagConfig key bag. + /// The inferred mode. + private static MqttMode InferMode(JsonObject o) + => SparkplugKeys.Any(k => !string.IsNullOrWhiteSpace(TagConfigJson.GetString(o, k))) + ? MqttMode.SparkplugB + : MqttMode.Plain; + + /// Reads an int value, or null when absent/null/not an integer (so an absent key stays absent). + /// The JSON object to read from. + /// The property name to read. + /// The int value, or null. + private static int? GetIntNullable(JsonObject o, string name) + => o.TryGetPropertyValue(name, out var n) && n is JsonValue v && v.TryGetValue(out var i) ? i : null; + + /// + /// Describes a present-but-invalid enum field, or null when it is absent or valid. + /// Mirrors TagConfigJson.TryReadEnum's absent/valid/invalid split exactly — including its + /// treatment of a present-but-non-string value as ABSENT — so the editor never blocks a blob the + /// driver would happily default. + /// + /// The enum type expected. + /// The TagConfig key bag. + /// The property name to describe. + /// The error text, or null. + private static string? DescribeInvalidEnum(JsonObject o, string name) where TEnum : struct, Enum + { + if (!o.TryGetPropertyValue(name, out var n) || n is not JsonValue v || !v.TryGetValue(out var raw)) + { + return null; + } + return Enum.TryParse(raw, ignoreCase: true, out _) + ? null + : $"'{raw}' is not a valid {name}; valid: {string.Join(", ", Enum.GetNames())}."; + } + + /// + /// Describes a present-but-invalid qos field, or null when it is absent or a legal + /// MQTT QoS. Matches the factory's strict read: absent ⇒ fine; a JSON integer 0–2 ⇒ fine; + /// anything else present (non-number, non-integer, out of range) ⇒ rejected. + /// + /// The TagConfig key bag. + /// The error text, or null. + private static string? DescribeInvalidQos(JsonObject o) + { + if (!o.TryGetPropertyValue(QosKey, out var n) || n is null) { return null; } + if (n is JsonValue v && v.TryGetValue(out var i) && i is >= 0 and <= 2) { return null; } + return $"'{n.ToJsonString()}' is not a valid QoS; valid: 0, 1, 2."; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs index 0ec4501b..8f53b287 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs @@ -21,6 +21,7 @@ public static class TagConfigEditorMap [DriverTypeNames.FOCAS] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor), [DriverTypeNames.OpcUaClient] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor), [DriverTypeNames.Calculation] = typeof(Components.Shared.Uns.TagEditors.CalculationTagConfigEditor), + [DriverTypeNames.Mqtt] = typeof(Components.Shared.Uns.TagEditors.MqttTagConfigEditor), }; /// Returns the editor component type for a driver type, or null if none is registered. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs index f73e69d4..a3c9a6b5 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs @@ -23,6 +23,7 @@ public static class TagConfigValidator [DriverTypeNames.FOCAS] = j => FocasTagConfigModel.FromJson(j).Validate(), [DriverTypeNames.OpcUaClient] = j => OpcUaClientTagConfigModel.FromJson(j).Validate(), [DriverTypeNames.Calculation] = j => CalculationTagConfigModel.FromJson(j).Validate(), + [DriverTypeNames.Mqtt] = j => MqttTagConfigModel.FromJson(j).Validate(), }; /// diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MqttTagConfigModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MqttTagConfigModelTests.cs new file mode 100644 index 00000000..3dfb3fbb --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MqttTagConfigModelTests.cs @@ -0,0 +1,254 @@ +using System.Text.Json.Nodes; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Mqtt; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +// Typed AdminUI editor model for the Mqtt driver (P1 = Plain mode). The model is a thin typed view over +// a preserved JsonObject key bag: every key the editor does not expose (history intent, array config, +// alarm objects, and the Sparkplug keys Task 24 will add) must survive a load→save untouched. +// +// The authoritative consumer of the produced blob is MqttTagDefinitionFactory.FromTagConfig +// (Driver.Mqtt.Contracts) — the key names + strictness rules asserted here are that factory's, not this +// editor's invention. TagConfig carries NO identity under v3 (the tag's RawPath is its identity), so +// there is deliberately no FullName key. +public sealed class MqttTagConfigModelTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("{}")] + [InlineData("not json at all")] + public void FromJson_returns_defaults_for_empty_input(string? json) + { + var m = MqttTagConfigModel.FromJson(json); + + m.Mode.ShouldBe(MqttMode.Plain); + m.Topic.ShouldBe(""); + m.PayloadFormat.ShouldBe(MqttPayloadFormat.Json); + m.JsonPath.ShouldBe(""); + m.DataType.ShouldBe(DriverDataType.String); + m.Qos.ShouldBeNull(); + m.RetainSeed.ShouldBeNull(); + } + + [Fact] + public void FromJson_reads_every_plain_field() + { + var m = MqttTagConfigModel.FromJson( + """{"topic":"factory/oven/temp","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64","qos":1,"retainSeed":false}"""); + + m.Mode.ShouldBe(MqttMode.Plain); + m.Topic.ShouldBe("factory/oven/temp"); + m.PayloadFormat.ShouldBe(MqttPayloadFormat.Json); + m.JsonPath.ShouldBe("$.value"); + m.DataType.ShouldBe(DriverDataType.Float64); + m.Qos.ShouldBe(1); + m.RetainSeed.ShouldBe(false); + } + + [Fact] + public void Round_trip_preserves_every_plain_field() + { + var m = new MqttTagConfigModel + { + Topic = "line3/press", + PayloadFormat = MqttPayloadFormat.Scalar, + JsonPath = "$.a.b", + DataType = DriverDataType.Int32, + Qos = 2, + RetainSeed = true, + }; + + var m2 = MqttTagConfigModel.FromJson(m.ToJson()); + + m2.Topic.ShouldBe("line3/press"); + m2.PayloadFormat.ShouldBe(MqttPayloadFormat.Scalar); + m2.JsonPath.ShouldBe("$.a.b"); + m2.DataType.ShouldBe(DriverDataType.Int32); + m2.Qos.ShouldBe(2); + m2.RetainSeed.ShouldBe(true); + } + + [Fact] + public void ToJson_emits_camelCase_keys_with_enum_names_and_no_identity_key() + { + var json = new MqttTagConfigModel + { + Topic = "a/b", + PayloadFormat = MqttPayloadFormat.Raw, + DataType = DriverDataType.Boolean, + }.ToJson(); + + json.ShouldContain("\"topic\":\"a/b\""); + // Enums MUST serialize as names — the driver factory reads them strictly by name, and an + // ordinal would be rejected outright (the systemic enum-serialization trap). + json.ShouldContain("\"payloadFormat\":\"Raw\""); + json.ShouldContain("\"dataType\":\"Boolean\""); + json.ShouldNotContain("\"payloadFormat\":1"); + json.ShouldNotContain("\"dataType\":0"); + // TagConfig carries no identity under v3 — the tag's RawPath is the driver-side key. + json.ShouldNotContain("FullName", Case.Sensitive); + } + + [Fact] + public void ToJson_omits_absent_optional_keys() + { + var json = new MqttTagConfigModel { Topic = "a/b", PayloadFormat = MqttPayloadFormat.Raw }.ToJson(); + + // Absent stays absent so the driver's own defaults (qos ⇒ DefaultQos, retainSeed ⇒ true, + // jsonPath ⇒ "$") apply, rather than this editor freezing them into the blob. + json.ShouldNotContain("qos"); + json.ShouldNotContain("retainSeed"); + json.ShouldNotContain("jsonPath"); + } + + [Fact] + public void FromJson_then_ToJson_preserves_unknown_keys() + { + var json = MqttTagConfigModel.FromJson( + """ + {"topic":"a/b","payloadFormat":"Raw","dataType":"String", + "isHistorized":true,"historianTagname":"Plant.A.B", + "array":{"valueRank":1,"dimensions":[4]}, + "alarm":{"type":"Hi","limit":90.5}, + "someFutureScalar":"keep me"} + """).ToJson(); + + var o = JsonNode.Parse(json)!.AsObject(); + + // History intent is authored by a different seam (TagHistorizeConfig) — dropping it here would + // silently un-historize the tag on the next edit. + o["isHistorized"]!.GetValue().ShouldBeTrue(); + o["historianTagname"]!.GetValue().ShouldBe("Plant.A.B"); + // Nested objects/arrays must survive structurally, not just as scalars. + o["array"]!["valueRank"]!.GetValue().ShouldBe(1); + o["array"]!["dimensions"]!.AsArray()[0]!.GetValue().ShouldBe(4); + o["alarm"]!["limit"]!.GetValue().ShouldBe(90.5); + o["someFutureScalar"]!.GetValue().ShouldBe("keep me"); + } + + [Fact] + public void FromJson_then_ToJson_preserves_sparkplug_keys_task24_will_own() + { + var json = MqttTagConfigModel.FromJson( + """{"groupId":"Plant1","edgeNodeId":"E1","deviceId":"D1","metricName":"Temp"}""").ToJson(); + + var o = JsonNode.Parse(json)!.AsObject(); + o["groupId"]!.GetValue().ShouldBe("Plant1"); + o["edgeNodeId"]!.GetValue().ShouldBe("E1"); + o["deviceId"]!.GetValue().ShouldBe("D1"); + o["metricName"]!.GetValue().ShouldBe("Temp"); + } + + [Fact] + public void FromJson_infers_SparkplugB_mode_from_the_descriptor_keys() + => MqttTagConfigModel + .FromJson("""{"groupId":"Plant1","edgeNodeId":"E1","metricName":"Temp"}""") + .Mode.ShouldBe(MqttMode.SparkplugB); + + // ── Validate: Plain rules ──────────────────────────────────────────────────────────────────── + + [Fact] + public void Validate_ValidPlain_ReturnsNull() + => MqttTagConfigModel.FromJson( + """{"topic":"a/b","payloadFormat":"Raw","dataType":"String"}""") + .Validate().ShouldBeNull(); + + [Fact] + public void Validate_MissingTopic_Fails() + => MqttTagConfigModel.FromJson("""{"payloadFormat":"Raw","dataType":"String"}""") + .Validate().ShouldNotBeNullOrWhiteSpace(); + + [Theory] + [InlineData("a/+/c")] + [InlineData("a/#")] + public void Validate_PlainWildcardTopic_Fails(string topic) + => MqttTagConfigModel.FromJson($$"""{"topic":"{{topic}}","payloadFormat":"Raw","dataType":"String"}""") + .Validate().ShouldNotBeNullOrWhiteSpace(); + + [Fact] + public void Validate_JsonWithoutPath_Fails() + => MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Json","dataType":"Float64"}""") + .Validate().ShouldNotBeNullOrWhiteSpace(); + + // Falsifiability control for the test above: the SAME blob with a jsonPath must validate clean, so + // the failure above is provably the missing jsonPath and not the topic or the dataType. + [Fact] + public void Validate_JsonWithPath_ReturnsNull() + => MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Json","jsonPath":"$","dataType":"Float64"}""") + .Validate().ShouldBeNull(); + + [Fact] + public void Validate_NonJsonFormat_DoesNotRequireJsonPath() + => MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Scalar","dataType":"Float64"}""") + .Validate().ShouldBeNull(); + + [Theory] + [InlineData("3")] + [InlineData("-1")] + [InlineData("\"high\"")] + [InlineData("1.5")] + public void Validate_InvalidQos_Fails(string qosLiteral) + => MqttTagConfigModel.FromJson($$"""{"topic":"a/b","payloadFormat":"Raw","dataType":"String","qos":{{qosLiteral}}}""") + .Validate().ShouldNotBeNullOrWhiteSpace(); + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void Validate_LegalQos_ReturnsNull(int qos) + => MqttTagConfigModel.FromJson($$"""{"topic":"a/b","payloadFormat":"Raw","dataType":"String","qos":{{qos}}}""") + .Validate().ShouldBeNull(); + + // The driver factory reads payloadFormat/dataType STRICTLY — a typo is rejected outright + // (BadNodeIdUnknown), never defaulted. The editor must not let such a blob past save while + // silently retyping it, so Validate reads the ORIGINAL key bag, not the defaulted typed field. + [Fact] + public void Validate_TypoedPayloadFormat_Fails() + => MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Jason","dataType":"String"}""") + .Validate().ShouldNotBeNullOrWhiteSpace(); + + [Fact] + public void Validate_TypoedDataType_Fails() + => MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Raw","dataType":"Double"}""") + .Validate().ShouldNotBeNullOrWhiteSpace(); + + // Once the editor has round-tripped the blob the typo is gone (ToJson rewrites the key with the + // typed field's name), so the strict check above can never wedge a tag the editor itself produced. + // NB a payloadFormat typo repairs to the Json default, which then legitimately demands a jsonPath — + // so the typo-repair claim is isolated here on dataType, whose default carries no follow-on rule. + [Fact] + public void Validate_AfterEditorRoundTrip_ClearsATypoedEnum() + { + var typoed = """{"topic":"a/b","payloadFormat":"Raw","dataType":"Double"}"""; + MqttTagConfigModel.FromJson(typoed).Validate().ShouldNotBeNullOrWhiteSpace(); // pre-condition + + var repaired = MqttTagConfigModel.FromJson(typoed).ToJson(); + + repaired.ShouldContain("\"dataType\":\"String\""); + MqttTagConfigModel.FromJson(repaired).Validate().ShouldBeNull(); + } + + // P1 stub: Sparkplug-shaped tags are not authored yet (Task 24 fills the rules). The Plain rules + // must NOT be applied to them — a Sparkplug tag has no topic. + [Fact] + public void Validate_SparkplugMode_IsNotSubjectToPlainRules() + => MqttTagConfigModel.FromJson("""{"groupId":"P1","edgeNodeId":"E1","metricName":"Temp"}""") + .Validate().ShouldBeNull(); + + // ── Dispatch registration ──────────────────────────────────────────────────────────────────── + + [Fact] + public void TagConfigEditorMap_resolves_the_Mqtt_editor() + => TagConfigEditorMap.Resolve("Mqtt").ShouldBe( + typeof(AdminUI.Components.Shared.Uns.TagEditors.MqttTagConfigEditor)); + + [Fact] + public void TagConfigValidator_dispatches_Mqtt() + => TagConfigValidator.Validate("Mqtt", """{"payloadFormat":"Raw"}""").ShouldNotBeNullOrWhiteSpace(); +} From f79d13e2d85f106016924e2e2da4f8279c13a88e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 17:24:13 -0400 Subject: [PATCH 20/43] feat(mqtt): factory + DriverTypeNames.Mqtt + host factory/probe registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 9. Adds MqttDriverFactoryExtensions (DriverTypeName = DriverTypeNames.Mqtt, direct deserialization of MqttDriverOptions — no intermediate DTO, mirroring OpcUaClientDriverFactoryExtensions), the DriverTypeNames.Mqtt constant, and both host wiring sites: the factory in DriverFactoryBootstrap.Register and the probe in AddOtOpcUaDriverProbes (the admin-node path Program.cs calls in its hasAdmin block — a probe wired only on driver nodes makes Test-connect silently dead). Carried-forward items: - Converges every MQTT config-parsing seam onto ONE JsonSerializerOptions — MqttJson.Options, in .Contracts alongside MqttDriverOptions. It replaces the probe's internal JsonOpts and the browser's separate private copy; the factory, the probe, MqttDriver.ParseOptions and MqttDriverBrowser now all parse through it. .Contracts is the only assembly all four consumers reference, and the browser's reference to the runtime .Driver project is a documented layering exception scheduled for removal — anchoring the options there would resurrect the duplicate the day it goes away. - Replaces the "Mqtt" literals in MqttDriverProbe, MqttDriverBrowser and MqttDriver with the constant (string value unchanged). - Tightens MqttDriverProbeTests.ProbeAsync_EnumAsName: it asserted only Ok == false + non-empty message, which is also exactly what a JSON-parse failure produces — so it stayed green under the very regression it names. It now asserts the probe got past the parse and reached the network. Falsifiability: deleting JsonStringEnumConverter from MqttJson.Options reddens 9 tests across 4 suites, including the tightened probe test (message becomes "Config JSON is invalid: The JSON value could not be converted to MqttProtocolVersion") — which the pre-fix assertions would have passed. Also references the MQTT driver from Core.Abstractions.Tests so DriverTypeNamesGuardTests' reflective bin scan discovers the new factory and the constant/factory parity check stays honest. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../DriverTypeNames.cs | 4 + .../MqttDriverBrowser.cs | 26 +-- .../MqttJson.cs | 53 ++++++ .../MqttDriver.cs | 15 +- .../MqttDriverFactoryExtensions.cs | 84 ++++++++++ .../MqttDriverProbe.cs | 26 +-- .../Drivers/DriverFactoryBootstrap.cs | 3 + .../ZB.MOM.WW.OtOpcUa.Host.csproj | 1 + ....WW.OtOpcUa.Core.Abstractions.Tests.csproj | 3 +- .../MqttDriverFactoryExtensionsTests.cs | 151 ++++++++++++++++++ .../MqttDriverOptionsTests.cs | 13 +- .../MqttDriverProbeTests.cs | 25 ++- 12 files changed, 344 insertions(+), 60 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttJson.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverFactoryExtensions.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverFactoryExtensionsTests.cs diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs index d4ba0417..6e65b231 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs @@ -53,6 +53,9 @@ public static class DriverTypeNames /// Calculation pseudo-driver — tags computed by C# scripts over other tags' live values. public const string Calculation = "Calculation"; + /// MQTT / Sparkplug B broker-subscription driver. + public const string Mqtt = "Mqtt"; + /// /// Every driver-type string declared above, for callers that need to enumerate /// the full set (e.g. validation of an authored DriverType). @@ -68,5 +71,6 @@ public static class DriverTypeNames OpcUaClient, Galaxy, Calculation, + Mqtt, ]; } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs index ce2f3366..42c049d6 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs @@ -1,11 +1,11 @@ using System.Buffers; using System.Text.Json; -using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using MQTTnet; using MQTTnet.Protocol; using ZB.MOM.WW.OtOpcUa.Commons.Browsing; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser; @@ -39,19 +39,6 @@ public sealed class MqttDriverBrowser : IDriverBrowser /// Marks the transient browse identity in the broker's client-id/session logs. internal const string BrowseClientIdPrefix = "-browse-"; - /// - /// Matches the runtime driver factory's parsing so a given DriverConfig JSON means the same - /// thing to the browser as it does to the deployed driver. JsonStringEnumConverter lets - /// mode / protocolVersion be authored as their string names — the natural form - /// for AdminUI-emitted JSON — while still accepting numeric ordinals. - /// - private static readonly JsonSerializerOptions JsonOpts = new() - { - UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, - PropertyNameCaseInsensitive = true, - Converters = { new JsonStringEnumConverter() }, - }; - private readonly ILogger _logger; /// @@ -64,10 +51,7 @@ public sealed class MqttDriverBrowser : IDriverBrowser _logger = logger ?? NullLogger.Instance; /// - // Literal rather than a constant: DriverTypeNames.Mqtt does not exist yet — Task 9 adds it, and - // owns that file. The string must stay EXACTLY "Mqtt": a DriverType string that drifts from the - // persisted one silently breaks driver dispatch (the repo's ModbusTcp/Modbus incident). - public string DriverType => "Mqtt"; + public string DriverType => DriverTypeNames.Mqtt; /// /// @@ -77,7 +61,11 @@ public sealed class MqttDriverBrowser : IDriverBrowser /// public async Task OpenAsync(string configJson, CancellationToken cancellationToken) { - var opts = JsonSerializer.Deserialize(configJson, JsonOpts) + // MqttJson.Options — the ONE shared instance across factory / probe / driver / browser + // (see its remarks). Parsing the same DriverConfig blob through a second, divergent + // JsonSerializerOptions is this repo's documented systemic enum bug: the picker would accept + // a `mode` / `protocolVersion` spelling the deployed driver rejects, or vice versa. + var opts = JsonSerializer.Deserialize(configJson, MqttJson.Options) ?? throw new InvalidOperationException("Mqtt options deserialized to null."); if (opts.Mode == MqttMode.SparkplugB) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttJson.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttJson.cs new file mode 100644 index 00000000..944f653d --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttJson.cs @@ -0,0 +1,53 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt; + +/// +/// The single instance every MQTT driver-config seam +/// parses through — the runtime factory +/// (MqttDriverFactoryExtensions), the Test-connect probe (MqttDriverProbe), the +/// driver's own ReinitializeAsync re-parse, and the address-picker browser +/// (MqttDriverBrowser). +/// +/// +/// +/// Why one instance, and why here. Divergent per-seam options are this repo's +/// documented systemic enum bug: an AdminUI-authored config with an enum-valued field is +/// accepted by the seam that carries a and faults the +/// one that does not, so "Test connect" goes green and the deployed driver dies. Two other +/// drivers already carry a copy per seam. Rather than repeat that, this driver keeps exactly +/// one instance. +/// +/// +/// It lives in .Contracts — the assembly that owns and +/// the three enums the converter exists for — rather than in the factory or the probe, +/// because .Contracts is the only assembly all four consumers already reference. +/// In particular the browser lives in its own assembly and reaches the runtime .Driver +/// project only through a deliberate, documented layering exception that is scheduled to be +/// removed (see the ProjectReference comment in the browser's csproj); anchoring the +/// options in .Driver would resurrect the duplicate the day that reference goes away. +/// +/// +/// Not a general-purpose JSON policy. UnmappedMemberHandling.Skip means an +/// unknown key is ignored rather than rejected — deliberate, so a config blob authored +/// against a newer driver still binds — and PropertyNameCaseInsensitive accepts both +/// the camelCase the AdminUI emits and the PascalCase a hand-edited blob may carry. A +/// becomes read-only on first use, so this instance is +/// safe to share across threads and must never be mutated after startup. +/// +/// +public static class MqttJson +{ + /// + /// The shared options. Enum-valued knobs (, + /// , ) round-trip by + /// name; numeric ordinals still bind, so an older blob is not broken by this. + /// + public static readonly JsonSerializerOptions Options = new() + { + PropertyNameCaseInsensitive = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + Converters = { new JsonStringEnumConverter() }, + }; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs index 5481f1cd..b7db7439 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs @@ -123,11 +123,7 @@ public sealed class MqttDriver public string DriverInstanceId => _driverInstanceId; /// - // Literal rather than a constant: DriverTypeNames.Mqtt does not exist yet — Task 9 adds it and - // owns that file. The string must stay EXACTLY "Mqtt" and match MqttDriverProbe.DriverType: a - // DriverType that drifts from the persisted one silently breaks dispatch (the ModbusTcp/Modbus - // incident). - public string DriverType => "Mqtt"; + public string DriverType => DriverTypeNames.Mqtt; /// /// The ingest path this driver composes. Internal: the P2 Sparkplug handler feeds the same @@ -513,10 +509,11 @@ public sealed class MqttDriver try { - // The probe's shared instance, deliberately: enums must round-trip by NAME. A second, - // divergent JsonSerializerOptions is this repo's documented systemic enum bug, where an - // AdminUI-authored config with a numeric enum field faults the driver. - return JsonSerializer.Deserialize(driverConfigJson, MqttDriverProbe.JsonOpts); + // MqttJson.Options — the ONE shared instance (factory / probe / this / browser), + // deliberately: enums must round-trip by NAME. A second, divergent + // JsonSerializerOptions is this repo's documented systemic enum bug, where an + // AdminUI-authored config with a string enum field faults the driver. + return JsonSerializer.Deserialize(driverConfigJson, MqttJson.Options); } catch (JsonException ex) { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverFactoryExtensions.cs new file mode 100644 index 00000000..dc6d9bea --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverFactoryExtensions.cs @@ -0,0 +1,84 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Hosting; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt; + +/// +/// Registers the MQTT / Sparkplug B driver with the . The +/// Host's DriverFactoryBootstrap calls once at startup; the +/// driver-instance bootstrapper then materialises DriverInstance rows of type +/// into live instances. +/// +/// +/// +/// Direct deserialization, no intermediate DTO. The DriverConfig blob binds +/// straight onto — the same shape +/// , MqttDriverBrowser and +/// already bind, and the shape the options record +/// was designed for (every knob carries its own default, and rawTags is a plain +/// list needing no translation). Mirrors +/// OpcUaClientDriverFactoryExtensions. ModbusDriverFactoryExtensions' separate +/// DTO exists to service a legacy nullable-everything blob plus string→enum parsing this +/// driver does with a converter instead; copying it here would add a fourth parse +/// shape for one config, which is precisely the divergence +/// exists to prevent. +/// +/// +/// Connection-free. parses and constructs only — the +/// constructor touches no network, and the registry contract +/// forbids the factory from calling InitializeAsync itself (the driver host owns the +/// retry semantics). +/// +/// +public static class MqttDriverFactoryExtensions +{ + /// + /// Driver type name — matches DriverInstance.DriverType values. Sourced from + /// so the registration key can never drift from the + /// constant the dispatch maps, the probe and the browser reference. + /// + public const string DriverTypeName = DriverTypeNames.Mqtt; + + /// + /// Register the MQTT factory with the driver registry. The optional + /// is captured at registration time and used to construct an + /// per driver instance — without it the driver runs with no + /// logger (standalone/test callers stay unchanged). + /// + /// The driver factory registry to register with. + /// Optional logger factory used to create per-instance loggers. + public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null) + { + ArgumentNullException.ThrowIfNull(registry); + registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory)); + } + + /// Public for the Server-side bootstrapper + test consumers. + /// Stable logical id of the driver instance. + /// The DriverConfig JSON blob for the instance. + /// Optional logger factory for the per-instance logger. + /// A configured, not-yet-connected . + /// + /// or is blank. + /// + /// The config blob deserialised to null. + /// The config blob is not valid JSON for the options shape. + public static MqttDriver CreateInstance( + string driverInstanceId, + string driverConfigJson, + ILoggerFactory? loggerFactory = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); + ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson); + + // MqttJson.Options — the one shared instance (see its remarks). A local copy here is the + // repo's documented systemic enum bug in the making. + var options = JsonSerializer.Deserialize(driverConfigJson, MqttJson.Options) + ?? throw new InvalidOperationException( + $"MQTT driver config for '{driverInstanceId}' deserialised to null"); + + return new MqttDriver(options, driverInstanceId, loggerFactory?.CreateLogger()); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverProbe.cs index ce8a71cc..4a6e973a 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverProbe.cs @@ -2,7 +2,6 @@ using System.Diagnostics; using System.Net.Sockets; using System.Security.Authentication; using System.Text.Json; -using System.Text.Json.Serialization; using Microsoft.Extensions.Logging.Abstractions; using MQTTnet; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; @@ -55,26 +54,8 @@ public sealed class MqttDriverProbe : IDriverProbe /// internal const string ProbeClientIdPrefix = "-probe-"; - /// - /// Shared JSON options for every MQTT config-parsing seam in this driver: enums - /// (, , protocolVersion) - /// round-trip by name, never ordinal — the repo's documented enum-serialization - /// trap (AdminUI-authored configs with numeric enum fields fault the driver). Task 9's - /// MqttDriverFactoryExtensions is expected to converge on this same instance - /// rather than defining its own. - /// - internal static readonly JsonSerializerOptions JsonOpts = new() - { - PropertyNameCaseInsensitive = true, - UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, - Converters = { new JsonStringEnumConverter() }, - }; - /// - // Literal rather than a constant: DriverTypeNames.Mqtt does not exist yet — Task 9 adds it, and - // owns that file. The string must stay EXACTLY "Mqtt": a DriverType string that drifts from the - // persisted one silently breaks driver dispatch (the repo's ModbusTcp/Modbus incident). - public string DriverType => "Mqtt"; + public string DriverType => DriverTypeNames.Mqtt; /// public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) @@ -82,7 +63,10 @@ public sealed class MqttDriverProbe : IDriverProbe MqttDriverOptions? options; try { - options = JsonSerializer.Deserialize(configJson, JsonOpts); + // MqttJson.Options — the one shared instance across factory / probe / driver / browser + // (see its remarks): enums must round-trip by NAME or an AdminUI-authored config that + // Test-connect accepts would fault the deployed driver. + options = JsonSerializer.Deserialize(configJson, MqttJson.Options); } catch (Exception ex) { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs index 71b5d0b7..fe19a128 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs @@ -18,6 +18,7 @@ using FocasProbe = Driver.FOCAS.FocasDriverProbe; using OpcUaProbe = Driver.OpcUaClient.OpcUaClientDriverProbe; using GalaxyProbe = Driver.Galaxy.GalaxyDriverProbe; using CalculationProbe = Driver.Calculation.CalculationDriverProbe; +using MqttProbe = Driver.Mqtt.MqttDriverProbe; /// /// Wires every cross-platform driver assembly's Register(registry, loggerFactory) @@ -122,6 +123,7 @@ public static class DriverFactoryBootstrap services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); return services; } @@ -144,6 +146,7 @@ public static class DriverFactoryBootstrap Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry); Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, secretResolver, loggerFactory); Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory); + Driver.Mqtt.MqttDriverFactoryExtensions.Register(registry, loggerFactory); Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory, secretResolver); Driver.S7.S7DriverFactoryExtensions.Register(registry); Driver.TwinCAT.TwinCATDriverFactoryExtensions.Register(registry); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj index dd7fbd9a..ebcea6c5 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj @@ -74,6 +74,7 @@ + diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj index b89bfe91..1e32d03c 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj @@ -21,11 +21,12 @@ - + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverFactoryExtensionsTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverFactoryExtensionsTests.cs new file mode 100644 index 00000000..ad4e2191 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverFactoryExtensionsTests.cs @@ -0,0 +1,151 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Hosting; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests; + +/// +/// Tests for — the seam that turns a persisted +/// DriverInstance.DriverConfig blob into a live . Mirrors +/// OpcUaClientDriverFactoryExtensions' shape (direct deserialization into the options +/// record, no intermediate DTO). +/// +[Trait("Category", "Unit")] +public sealed class MqttDriverFactoryExtensionsTests +{ + [Fact] + public void Register_ThenCreate_BuildsMqttDriver() + { + var registry = new DriverFactoryRegistry(); + MqttDriverFactoryExtensions.Register(registry); + + var factory = registry.TryGet(MqttDriverFactoryExtensions.DriverTypeName); + factory.ShouldNotBeNull(); + + var driver = factory("d1", """{"host":"h","port":1883,"mode":"Plain"}"""); + + driver.ShouldBeOfType(); + } + + /// + /// The factory's registration key and the canonical constant must be the same string. A + /// drift here is invisible at compile time and silently breaks dispatch — the repo's + /// ModbusTcp/Modbus incident. + /// + [Fact] + public void DriverTypeName_MatchesConstant() + => MqttDriverFactoryExtensions.DriverTypeName.ShouldBe(DriverTypeNames.Mqtt); + + /// + /// Third independent pin on the literal itself: both the constant and the factory name could + /// be renamed together and still agree with each other while disagreeing with every persisted + /// DriverInstance.DriverType row. + /// + [Fact] + public void DriverTypeName_IsExactlyMqtt() + { + MqttDriverFactoryExtensions.DriverTypeName.ShouldBe("Mqtt"); + DriverTypeNames.Mqtt.ShouldBe("Mqtt"); + DriverTypeNames.All.ShouldContain("Mqtt"); + } + + /// + /// rawTags is the ONLY source of the driver's discoverable node set (plain MQTT has no + /// browsable address space), so a factory that parsed everything else correctly but dropped + /// this array would produce a driver that connects, subscribes to nothing and materializes + /// nothing — green everywhere, dark in production. + /// + [Fact] + public async Task CreateInstance_CarriesRawTagsThroughToDiscovery() + { + const string tagConfig = """{"topic":"f/t","payloadFormat":"Raw","dataType":"String"}"""; + var json = JsonSerializer.Serialize(new + { + host = "h", + port = 1883, + mode = "Plain", + rawTags = new[] + { + new { rawPath = "Plant/Mqtt/dev1/Temp", tagConfig, writeIdempotent = false }, + }, + }); + + var driver = MqttDriverFactoryExtensions.CreateInstance("d1", json); + + var builder = new RecordingBuilder(); + await driver.DiscoverAsync(builder, CancellationToken.None); + + builder.VariableFullNames.ShouldBe(["Plant/Mqtt/dev1/Temp"]); + } + + /// + /// Enum-valued knobs must bind from their string NAMES — the repo's documented systemic + /// enum-serialization bug is an AdminUI-authored config whose enum field one seam can read + /// and another cannot. Removing from + /// makes this throw. + /// + [Fact] + public void CreateInstance_EnumsAuthoredAsNames_Bind() + { + const string json = """ + {"host":"h","port":8883,"mode":"SparkplugB","protocolVersion":"V311", + "sparkplug":{"groupId":"Plant1","hostId":"h1"}} + """; + + var driver = MqttDriverFactoryExtensions.CreateInstance("d1", json); + + driver.ShouldBeOfType(); + } + + /// + /// Direct pin on the single shared instance: the enum converter is what the test above + /// exercises behaviourally, and this asserts it structurally so a reader can see the one + /// instance every MQTT config seam parses through. + /// + [Fact] + public void SharedJsonOptions_CarryTheStringEnumConverter() + => MqttJson.Options.Converters.ShouldContain(c => c is JsonStringEnumConverter); + + [Fact] + public void CreateInstance_BlankConfig_Throws() + => Should.Throw(() => MqttDriverFactoryExtensions.CreateInstance("d1", " ")); + + [Fact] + public void CreateInstance_JsonNullLiteral_ThrowsInvalidOperation() + => Should.Throw(() => MqttDriverFactoryExtensions.CreateInstance("d1", "null")); + + /// + /// Minimal discovery recorder — local to this suite because the driver test project does not + /// reference Commons, where the runtime's capturing builder lives (same reason + /// MqttDriverDiscoveryTests carries its own). + /// + private sealed class RecordingBuilder : IAddressSpaceBuilder + { + public List VariableFullNames { get; } = []; + + public IAddressSpaceBuilder Folder(string browseName, string displayName) => this; + + public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) + { + VariableFullNames.Add(attributeInfo.FullName); + return new Handle(attributeInfo.FullName); + } + + public void AddProperty(string browseName, DriverDataType dataType, object? value) { } + + private sealed class Handle(string fullRef) : IVariableHandle + { + public string FullReference => fullRef; + + public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink(); + } + + private sealed class NullSink : IAlarmConditionSink + { + public void OnTransition(AlarmEventArgs args) { } + } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverOptionsTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverOptionsTests.cs index ce7e2c3c..d6fc1cb9 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverOptionsTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverOptionsTests.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using System.Text.Json.Serialization; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.Mqtt; @@ -8,12 +7,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests; public sealed class MqttDriverOptionsTests { - private static readonly JsonSerializerOptions J = new() - { - PropertyNameCaseInsensitive = true, - UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, - Converters = { new JsonStringEnumConverter() }, - }; + /// + /// The production instance, not a look-alike copy. A local clone would keep passing after + /// lost its JsonStringEnumConverter — testing the copy + /// instead of the thing every seam actually parses through. + /// + private static readonly JsonSerializerOptions J = MqttJson.Options; [Fact] public void Deserialize_SparkplugConfig_ReadsModeAndSubObject() diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverProbeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverProbeTests.cs index 14c70eab..5f3e1b5e 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverProbeTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverProbeTests.cs @@ -161,10 +161,20 @@ public sealed class MqttDriverProbeTests r.Message.ShouldNotContain(secret); } - /// Enum fields (protocolVersion) must round-trip by name, per the repo's documented - /// enum-serialization trap — a numeric-only seam faults AdminUI-authored configs. + /// + /// Enum fields (protocolVersion) must round-trip by name, per the repo's documented + /// enum-serialization trap — a numeric-only seam faults AdminUI-authored configs. + /// + /// The assertions are the whole point. "Ok is false with a non-empty message" is + /// ALSO what a JSON-parse failure produces, so asserting only that would stay green if + /// JsonStringEnumConverter were ever dropped from + /// — i.e. the exact regression this test exists to catch. So it asserts the probe got + /// PAST the parse (message is not the "Config JSON is invalid" shape) and reached + /// the network (the message names the target endpoint). + /// + /// [Fact] - public async Task ProbeAsync_EnumAsName_DoesNotThrow() + public async Task ProbeAsync_EnumAsName_ParsesAndReachesTheNetwork() { var listener = StartListener(); var port = ListenerPort(listener); @@ -178,5 +188,14 @@ public sealed class MqttDriverProbeTests r.Ok.ShouldBeFalse(); r.Message.ShouldNotBeNullOrEmpty(); + r.Message.ShouldNotStartWith( + "Config JSON is invalid", + Case.Insensitive, + "the probe never parsed the config — 'V500' was rejected, so the shared JSON options " + + "lost their JsonStringEnumConverter"); + r.Message.ShouldNotStartWith("Config JSON deserialized to null"); + r.Message.ShouldNotStartWith("Config has no host/port"); + // A network-level marker: the probe got as far as dialling the (closed) loopback port. + r.Message.ShouldContain($"127.0.0.1:{port}"); } } From a13ae926a8bde324874861472171d71c5ce86267 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 17:31:04 -0400 Subject: [PATCH 21/43] fix(mqtt): gate DisposeAsync against in-flight lifecycle ops (C1) + rebuild-branch tests (I1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C1 (Critical) — DisposeAsync went straight to TeardownAsync with no lifecycle-gate wait, reopening the orphaned-connection class. Interleaving: ReinitializeAsync's rebuild branch holds the gate mid-InitializeCoreAsync, past its own teardown but before `_connection = connection`; an ungated dispose sees a null connection, does nothing, and sets `_disposed`; the rebuild then publishes a live connection plus a host-probe loop that every later dispose short-circuits past. Not proven reachable today (DriverInstanceActor.PostStop calls the gated ShutdownAsync), but MqttDriver publicly implements IAsyncDisposable, which invites `await using`. - DisposeAsync now takes the gate with the same bounded wait + fallback teardown ShutdownAsync uses, and sets `_disposed` BEFORE the wait. - InitializeCoreAsync re-checks `_disposed` after the connect and disposes the connection it just built rather than publishing it — this closes the residual window on the bounded-timeout fallback path. - The doc comment no longer claims parity with ShutdownAsync it did not have, and stops conflating "don't Dispose() the semaphore object" with "don't WaitAsync". I1 (Important) — the SameSession == false rebuild branch had no test driving it. Adds three: an endpoint-changing delta rebuilds and Faults on a refused connect; an ingest-only delta (MaxPayloadBytes) ALSO rebuilds, pinning that IngestIdentity is nested inside SessionIdentity; and DisposeAsync serializes behind a lifecycle operation parked at a new internal BeforeConnectHookForTests seam (mirroring MqttConnection's AfterConnectHookForTests, which exists for the same race class). Minors: comments recording that IngestIdentity names no Sparkplug field and must grow one in P2 (tasks 21/22), and why _options/_subscriptions/_authoredRawPaths get looser memory discipline than _health/_hostState. Falsifiability: reverting DisposeAsync to the ungated form reddens exactly the new serialization test ("Shouldly.ShouldAssertException : raced"); forcing SameSession to always-true reddens exactly the two new rebuild tests. 240/240 MQTT tests pass; forced rebuild of the driver project is 0 warnings under TreatWarningsAsErrors. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../MqttDriver.cs | 102 ++++++++++++++++- .../MqttDriverDiscoveryTests.cs | 107 ++++++++++++++++++ 2 files changed, 203 insertions(+), 6 deletions(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs index b7db7439..da500db5 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs @@ -72,6 +72,14 @@ public sealed class MqttDriver /// Guards / / against each other. private readonly SemaphoreSlim _lifecycleGate = new(1, 1); + // _options / _subscriptions / _authoredRawPaths are written only under _lifecycleGate (or in the + // ctor) and read freely elsewhere, WITHOUT the Volatile discipline _health / _hostState get. + // That is deliberate, not an oversight: all three are reference/interface fields, so a reader + // sees either the whole old object or the whole new one — never a torn one. The looser fields + // are the ones a reader may legitimately observe one publish stale (a read served from the + // previous authored table is a correct read of a value that was correct a microsecond ago); + // _health and _hostState feed ServiceLevel and the status dashboard, where a stale per-core copy + // is an operator-visible lie about whether the driver is up. private MqttDriverOptions _options; private MqttSubscriptionManager _subscriptions; @@ -133,6 +141,16 @@ public sealed class MqttDriver /// internal MqttSubscriptionManager Subscriptions => _subscriptions; + /// + /// Test seam: awaited inside while the lifecycle gate is + /// held, immediately before the broker connect. Lets a test park a lifecycle operation + /// mid-flight and prove serializes behind it rather than racing + /// past a half-built session. Mirrors 's own + /// AfterConnectHookForTests, which exists for the same class of race. Always + /// in production. + /// + internal Func? BeforeConnectHookForTests { get; set; } + // ---- IDriver: lifecycle ---- /// @@ -437,9 +455,37 @@ public sealed class MqttDriver public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult(); /// - /// Performs the same teardown as so a caller that uses - /// await using without an explicit shutdown does not leak a live broker session. + /// Performs the same teardown as , under the same lifecycle + /// gate, so a caller that uses await using without an explicit shutdown does not + /// leak a live broker session. /// + /// + /// + /// The gate wait is the whole point, not ceremony. An ungated dispose that ran + /// while 's rebuild branch held the gate would observe + /// _connection == null — the rebuild has torn the old session down but not yet + /// assigned the new one — do nothing, and set _disposed. The rebuild would then + /// complete, assign a live, connected connection and start a host-probe loop that + /// no later dispose can ever reach, because the disposed guard short-circuits them all. + /// That is the orphaned-connection class 's own remarks + /// describe having already fixed once. Waiting for the gate makes this dispose tear down + /// whatever the in-flight operation ends up assigning. + /// + /// + /// _disposed is set before the wait, so an in-flight + /// sees it at its post-connect re-check and refuses to + /// publish the connection at all — which is what closes the residual window on the + /// bounded-timeout fallback path below. + /// + /// + /// Separately (and for an unrelated reason): the gate object itself is deliberately never + /// Dispose()d — same call as 's semaphores. A caller + /// that disposes before its last would otherwise get an + /// naming , which says + /// nothing useful; the gate holds no timer and no surviving registration, so leaving it + /// undisposed costs nothing. + /// + /// /// A task that represents the asynchronous dispose. public async ValueTask DisposeAsync() { @@ -448,10 +494,27 @@ public sealed class MqttDriver return; } - // The lifecycle gate is deliberately NOT disposed — same call as MqttConnection's semaphores. - // A caller that disposes before its last ShutdownAsync would otherwise get an - // ObjectDisposedException naming SemaphoreSlim, which says nothing useful; the gate holds no - // timer and no surviving registration, so leaving it undisposed costs nothing. + if (await _lifecycleGate.WaitAsync(TimeSpan.FromSeconds(_options.ConnectTimeoutSeconds)) + .ConfigureAwait(false)) + { + try + { + await TeardownAsync().ConfigureAwait(false); + } + finally + { + _lifecycleGate.Release(); + } + + return; + } + + // Bounded even here: a wedged initialize must not turn dispose into a hang. The post-connect + // disposed re-check in InitializeCoreAsync is what stops that operation from publishing a + // connection behind this teardown's back. + _logger?.LogWarning( + "MQTT driver '{DriverId}': lifecycle gate still held at dispose; tearing the session down anyway.", + _driverInstanceId); await TeardownAsync().ConfigureAwait(false); } @@ -480,7 +543,24 @@ public sealed class MqttDriver // throw-on-total-failure is what tears a deaf session down and retries it. _subscriptions.AttachTo(connection); + if (BeforeConnectHookForTests is { } hook) + { + await hook(cancellationToken).ConfigureAwait(false); + } + await connection.ConnectAsync(cancellationToken).ConfigureAwait(false); + + // Re-check disposal AFTER the connect, before publishing the connection. DisposeAsync + // normally waits for this gate, but its wait is bounded — on the wedged-gate fallback + // path it can run concurrently with this method, and assigning a live connection behind + // a completed teardown is exactly the orphaned-session bug. Losing the race means + // disposing what we just built, never leaking it. + if (Volatile.Read(ref _disposed) != 0) + { + await connection.DisposeAsync().ConfigureAwait(false); + throw new ObjectDisposedException(nameof(MqttDriver)); + } + _connection = connection; WriteHealth(new DriverHealth(DriverState.Healthy, connection.LastMessageUtc, null)); @@ -597,6 +677,16 @@ public sealed class MqttDriver => SessionIdentity(a).Equals(SessionIdentity(b)); /// Whether two option sets produce an identical . + /// + /// ⚠️ P2 (Sparkplug, tasks 21/22) must extend . It names + /// only the settings the P1 manager captures at construction — Mode, + /// Plain.DefaultQos, MaxPayloadBytes — and deliberately no + /// field, because nothing reads one yet. The moment a + /// Sparkplug handler captures its own options (group id, host id, rebirth policy, birth + /// window) at construction, a delta changing one of them would be judged "same ingest", + /// applied in place, and silently never reach the component that needed rebuilding — the + /// driver would keep subscribing to the OLD group id and report perfect health. + /// private static bool SameIngest(MqttDriverOptions a, MqttDriverOptions b) => IngestIdentity(a).Equals(IngestIdentity(b)); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs index 70a92223..4fbd1c96 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs @@ -28,6 +28,33 @@ public sealed class MqttDriverDiscoveryTests private static MqttDriver PlainDriver(params RawTagEntry[] tags) => new(new MqttDriverOptions { Mode = MqttMode.Plain, RawTags = tags }, "d", null); + /// + /// Options aimed at a definitely-closed port so a connect is refused immediately rather + /// than hanging — the tests that must prove a rebuild actually dials need the dial to fail + /// fast, not to burn a connect deadline. + /// + private static MqttDriverOptions ClosedPortOptions(params RawTagEntry[] tags) => new() + { + Mode = MqttMode.Plain, + Host = "127.0.0.1", + Port = 1, + UseTls = false, + ConnectTimeoutSeconds = 2, + RawTags = tags, + }; + + private static MqttDriver ClosedPortDriver(params RawTagEntry[] tags) + => new(ClosedPortOptions(tags), "d", null); + + /// + /// Serializes a full options record as the reinitialize blob. Round-tripping the whole record + /// (rather than hand-writing a partial JSON object) guarantees that only the field the caller + /// changed differs — a hand-written delta silently omitting a session field would take the + /// rebuild branch for the wrong reason and the test would pass vacuously. + /// + private static string DeltaJson(MqttDriverOptions options) + => System.Text.Json.JsonSerializer.Serialize(options, MqttJson.Options); + /// /// Plain mode replays ONLY the authored tag set: one variable per authored raw tag, a /// single discovery pass (), and no online @@ -192,6 +219,86 @@ public sealed class MqttDriverDiscoveryTests .ShouldBe(["Plant/Mqtt/dev1/Flow", "Plant/Mqtt/dev1/Pressure"]); } + /// + /// A delta that changes the broker endpoint takes the rebuild branch: it must actually + /// dial the new endpoint (proven here by the refused connect), and a rebuild whose connect + /// fails must land rather than letting the failure escape + /// with the health surface still claiming Healthy. + /// + [Fact] + public async Task ReinitializeAsync_SessionChangingDelta_Rebuilds_AndFaultsOnUnreachableBroker() + { + var driver = ClosedPortDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t")); + + // Only Host differs from the ctor options — everything else round-trips identically. + var delta = DeltaJson(ClosedPortOptions() with { Host = "127.0.0.2" }); + + await Should.ThrowAsync(() => driver.ReinitializeAsync(delta, CancellationToken.None)); + + driver.GetHealth().State.ShouldBe(DriverState.Faulted); + } + + /// + /// Pins that IngestIdentity is nested inside SessionIdentity: a delta touching + /// ONLY an ingest setting still rebuilds. Without the nesting it would be judged "same + /// session", applied in place, and the subscription manager that actually reads + /// MaxPayloadBytes would never be rebuilt — a config change that silently does nothing. + /// + [Fact] + public async Task ReinitializeAsync_IngestOnlyDelta_AlsoRebuilds() + { + var driver = ClosedPortDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t")); + + var delta = DeltaJson(ClosedPortOptions() with { MaxPayloadBytes = 4096 }); + + // Reaching the (refused) connect at all is the proof the rebuild branch was taken; the + // tag-only test above is the control that shows an in-place delta never dials. + await Should.ThrowAsync(() => driver.ReinitializeAsync(delta, CancellationToken.None)); + } + + /// + /// The falsifiability pin for the orphaned-connection class: + /// must serialize behind an in-flight lifecycle operation, not race past it. + /// + /// + /// An ungated dispose running while a rebuild holds the gate observes _connection == null + /// (the rebuild has torn the old session down but not yet assigned the new one), does nothing, + /// and sets the disposed flag — after which the rebuild assigns a live connection plus a + /// host-probe loop that no later dispose can reach. Here the driver is parked inside + /// InitializeCoreAsync at the pre-connect hook; the assertion is that dispose does not + /// complete while it is parked, and does complete once it is released. + /// + [Fact] + public async Task DisposeAsync_SerializesBehindAnInFlightLifecycleOperation() + { + var driver = ClosedPortDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t")); + + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + driver.BeforeConnectHookForTests = async _ => + { + entered.TrySetResult(); + await release.Task; + }; + + var initialize = Task.Run(() => driver.InitializeAsync("", CancellationToken.None)); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + var dispose = driver.DisposeAsync().AsTask(); + + // The gate is held by the parked initialize. Dispose must still be waiting. (The gate wait is + // bounded by ConnectTimeoutSeconds = 2 s, so this 300 ms window is comfortably inside it — + // an ungated dispose returns essentially instantly.) + var raced = await Task.WhenAny(dispose, Task.Delay(TimeSpan.FromMilliseconds(300))); + raced.ShouldNotBe(dispose, "DisposeAsync returned while a lifecycle operation held the gate"); + + release.SetResult(); + await Should.ThrowAsync(() => initialize); // connect refused on the closed port + + await dispose.WaitAsync(TimeSpan.FromSeconds(10)); + dispose.IsCompletedSuccessfully.ShouldBeTrue(); + } + /// Identity is fixed at construction and must match the persisted DriverInstance.DriverType. [Fact] public void Identity_IsMqtt() From ff62b62a55b1c11d8fd84cdf4e5f0eec634b883e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 17:37:13 -0400 Subject: [PATCH 22/43] refactor(mqtt): accept a blank jsonPath; guide with a seeded "$" instead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up. Dropped the hard Validate rejection of a blank jsonPath under a Json payload: the runtime defaults it to the document root, which is the real "publisher puts a bare JSON scalar on the topic" case, so rejecting it made the editor refuse what the driver accepts — inverting "authoring surface accepts <=> publish accepts" — and blocked whole CSV-import batches, since this validator also gates RawManualTagEntryModal's review grid. Replaced with guidance: an UNAUTHORED tag (no topic AND no jsonPath) seeds the field with "$", so a fresh tag emits an explicit "jsonPath":"$" while an existing path-less tag keeps the key ABSENT and the driver defaults it. The wildcard-topic rule stays strict — a wildcard has no legitimate single-Tag interpretation. Also: omit a blank "topic" rather than writing "topic":"", and record why the _lastConfigJson guard diverges from the Modbus template — the landmine is a DERIVED, NON-PERSISTED UI FIELD INFERRED FROM BLOB CONTENT (Mode), which Task 24's Sparkplug field group will be tempted to add more of. Named the host-side dependency (RawTagModal only mutates through this callback) that keeps the staleness trade benign today. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../Uns/TagEditors/MqttTagConfigEditor.razor | 18 ++++- .../Uns/TagEditors/MqttTagConfigModel.cs | 71 ++++++++++++++----- .../Uns/MqttTagConfigModelTests.cs | 46 ++++++++++-- 3 files changed, 109 insertions(+), 26 deletions(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/MqttTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/MqttTagConfigEditor.razor index 810fe331..416e58ed 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/MqttTagConfigEditor.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/MqttTagConfigEditor.razor @@ -133,8 +133,22 @@ apply(); _validationError = _m.Validate(); var json = _m.ToJson(); - // Keep the guard in OnParametersSet in step with what we just emitted, so the host echoing the - // new JSON back as a parameter cannot re-parse and clobber an in-progress edit. + + // Keep the OnParametersSet guard in step with what we just emitted, so the host echoing the new + // JSON straight back as a parameter cannot re-parse and clobber an in-progress edit. + // + // WHY THIS DIVERGES FROM THE MODBUS TEMPLATE (which does not do this): the general landmine is a + // DERIVED, NON-PERSISTED UI FIELD INFERRED FROM BLOB CONTENT. Mode is exactly that — it is + // re-inferred by FromJson from the Sparkplug keys and never serialised, so a plain re-parse of + // our own output would silently reset the operator's shape selection to Plain on the very next + // render. Modbus has no such field, which is why it needs no guard. Task 24's Sparkplug field + // group will be tempted to add more of them; each one needs this guard to hold. + // + // TRADE (does not manifest today): the editor will not pick up a change the HOST makes to the + // JSON while it is open. That is safe only because RawTagModal/RawManualTagEntryModal mutate + // _form.TagConfig exclusively through this callback. A host-side feature that rewrites the blob + // out-of-band — a "reformat JSON" action, a bulk address re-pick — would reintroduce staleness + // here and must re-key the guard (e.g. compare against a host-supplied revision, not the text). _lastConfigJson = json; await ConfigJsonChanged.InvokeAsync(json); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MqttTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MqttTagConfigModel.cs index 6d52fbac..6b9e3b4c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MqttTagConfigModel.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MqttTagConfigModel.cs @@ -38,6 +38,13 @@ public sealed class MqttTagConfigModel private const string QosKey = "qos"; private const string RetainSeedKey = "retainSeed"; + /// + /// The JSONPath the driver applies when the blob omits jsonPath — the document root. + /// Seeded into an unauthored tag's field so the "no extraction needed" case is one click away + /// rather than something the operator has to know. + /// + private const string RootJsonPath = "$"; + /// The Sparkplug B descriptor keys — authored by Task 24, preserved (never dropped) in P1. private static readonly string[] SparkplugKeys = ["groupId", "edgeNodeId", "deviceId", "metricName"]; @@ -57,8 +64,11 @@ public sealed class MqttTagConfigModel public MqttPayloadFormat PayloadFormat { get; set; } = MqttPayloadFormat.Json; /// - /// JSONPath selecting the value inside a payload. Blank ⇒ - /// the key is omitted and the driver applies the document root ($). + /// JSONPath selecting the value inside a payload. Blank is + /// legal and NOT a validation failure — the key is omitted and the driver applies the document + /// root ($), which is the real "publisher puts a bare JSON scalar on the topic" case. + /// An unauthored tag is seeded with $ by so that common case + /// is visible and one click away; see . /// public string JsonPath { get; set; } = ""; @@ -84,17 +94,29 @@ public sealed class MqttTagConfigModel /// original key (so fields this editor doesn't expose — history intent, array/alarm objects, and /// the Sparkplug descriptor keys — survive a load→save). /// + /// + /// An unauthored Plain tag — no topic AND no jsonPath, i.e. a brand-new tag + /// rather than an existing one the operator deliberately left path-less — has its + /// seeded to . The condition is deliberately + /// narrow: an existing tag that carries a topic and no jsonPath keeps the key ABSENT + /// through a load→save, so this can never rewrite already-deployed blobs. + /// /// The raw TagConfig JSON string, or null for a new/empty config. /// The populated . public static MqttTagConfigModel FromJson(string? json) { var o = TagConfigJson.ParseOrNew(json); + var topic = TagConfigJson.GetString(o, TopicKey) ?? ""; + var jsonPath = TagConfigJson.GetString(o, JsonPathKey) ?? ""; + var mode = InferMode(o); + if (mode == MqttMode.Plain && topic.Length == 0 && jsonPath.Length == 0) { jsonPath = RootJsonPath; } + return new MqttTagConfigModel { - Mode = InferMode(o), - Topic = TagConfigJson.GetString(o, TopicKey) ?? "", + Mode = mode, + Topic = topic, PayloadFormat = TagConfigJson.GetEnum(o, PayloadFormatKey, MqttPayloadFormat.Json), - JsonPath = TagConfigJson.GetString(o, JsonPathKey) ?? "", + JsonPath = jsonPath, DataType = TagConfigJson.GetEnum(o, DataTypeKey, DriverDataType.String), Qos = GetIntNullable(o, QosKey), RetainSeed = TagConfigJson.GetBoolNullable(o, RetainSeedKey), @@ -111,7 +133,9 @@ public sealed class MqttTagConfigModel /// The serialised TagConfig JSON string. public string ToJson() { - TagConfigJson.Set(_bag, TopicKey, Topic.Trim()); + // Blank ⇒ omit, like every other optional here: toggling the shape dropdown on an untouched tag + // must not leave a stray "topic":"" behind. + TagConfigJson.Set(_bag, TopicKey, string.IsNullOrWhiteSpace(Topic) ? null : Topic.Trim()); TagConfigJson.Set(_bag, PayloadFormatKey, PayloadFormat); TagConfigJson.Set(_bag, DataTypeKey, DataType); TagConfigJson.Set(_bag, JsonPathKey, string.IsNullOrWhiteSpace(JsonPath) ? null : JsonPath.Trim()); @@ -125,14 +149,26 @@ public sealed class MqttTagConfigModel /// Returns the first error, or null when the config is valid. /// /// - /// Two rules are deliberately stricter than the runtime parser, because this is an - /// authoring surface and the cheapest place to stop the mistake: a wildcard topic (the runtime - /// accepts it and Inspect only warns at deploy) and a Json payload with no - /// jsonPath (the runtime defaults to the document root). Everything else — required - /// topic, strict enums, QoS 0–2 — matches MqttTagDefinitionFactory exactly. The strict - /// enum/QoS checks read the ORIGINAL key bag, not the defaulted typed fields, so a blob that - /// never went through this editor (e.g. a CSV import) cannot pass validation while the driver - /// would reject it. + /// + /// Exactly ONE rule is deliberately stricter than the runtime parser: a wildcard topic, + /// which the runtime accepts and Inspect only warns about at deploy. It earns the + /// strictness because a wildcard has no legitimate single-Tag interpretation — one Tag holds one + /// value, and +/# would feed it from many source topics. Everything else — + /// required topic, strict enums, QoS 0–2 — matches MqttTagDefinitionFactory exactly. + /// + /// + /// A blank jsonPath under a Json payload is explicitly accepted, matching + /// the runtime's document-root default. Rejecting it would make this editor refuse a config the + /// driver handles happily — inverting the "authoring surface accepts ⇔ publish accepts" + /// principle — and, because this validator also gates the CSV-import review grid + /// (RawManualTagEntryModal), would block whole import batches over a sane default. The + /// operator is guided by the seeded $ from instead of a blocker. + /// + /// + /// The strict enum/QoS checks read the ORIGINAL key bag, not the defaulted typed fields, so a + /// blob that never went through this editor (e.g. a CSV import) cannot pass validation while the + /// driver would reject it. + /// /// /// An error message describing the validation failure, or null when valid. public string? Validate() @@ -153,11 +189,8 @@ public sealed class MqttTagConfigModel + "or the tag would be fed by every matching topic."; } - if (PayloadFormat == MqttPayloadFormat.Json && string.IsNullOrWhiteSpace(JsonPath)) - { - return "A jsonPath is required when the payload format is Json (use \"$\" for the whole document)."; - } - + // NB no jsonPath rule — see the remarks above. A blank path is the driver's document-root + // default, not an authoring error. return null; } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MqttTagConfigModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MqttTagConfigModelTests.cs index 3dfb3fbb..b803fbd4 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MqttTagConfigModelTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MqttTagConfigModelTests.cs @@ -30,7 +30,9 @@ public sealed class MqttTagConfigModelTests m.Mode.ShouldBe(MqttMode.Plain); m.Topic.ShouldBe(""); m.PayloadFormat.ShouldBe(MqttPayloadFormat.Json); - m.JsonPath.ShouldBe(""); + // An UNAUTHORED tag seeds the document-root path so the common "no extraction needed" case is + // visible and one click away — it is a guide, not a requirement (blank is accepted, see below). + m.JsonPath.ShouldBe("$"); m.DataType.ShouldBe(DriverDataType.String); m.Qos.ShouldBeNull(); m.RetainSeed.ShouldBeNull(); @@ -107,6 +109,15 @@ public sealed class MqttTagConfigModelTests json.ShouldNotContain("jsonPath"); } + [Fact] + public void ToJson_omits_a_blank_topic() + { + // Toggling the shape dropdown on an untouched tag must not leave a stray "topic":"" behind. + var json = new MqttTagConfigModel { PayloadFormat = MqttPayloadFormat.Raw }.ToJson(); + + json.ShouldNotContain("topic"); + } + [Fact] public void FromJson_then_ToJson_preserves_unknown_keys() { @@ -171,13 +182,38 @@ public sealed class MqttTagConfigModelTests => MqttTagConfigModel.FromJson($$"""{"topic":"{{topic}}","payloadFormat":"Raw","dataType":"String"}""") .Validate().ShouldNotBeNullOrWhiteSpace(); + // A blank jsonPath under a Json payload is ACCEPTED — the runtime defaults it to the document root + // ("$"), which is the real "publisher puts a bare JSON scalar on the topic" case. Rejecting it would + // make the editor refuse what the driver accepts, and would block CSV-import batches (this validator + // also gates RawManualTagEntryModal's review grid) over a sane default. [Fact] - public void Validate_JsonWithoutPath_Fails() + public void Validate_JsonWithoutPath_IsAccepted() => MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Json","dataType":"Float64"}""") - .Validate().ShouldNotBeNullOrWhiteSpace(); + .Validate().ShouldBeNull(); + + // …and the key stays ABSENT through a load→save, so the driver applies its own default rather than + // this editor freezing "$" into an already-deployed blob. + [Fact] + public void ToJson_leaves_an_existing_blank_jsonPath_absent() + { + var json = MqttTagConfigModel + .FromJson("""{"topic":"a/b","payloadFormat":"Json","dataType":"Float64"}""") + .ToJson(); + + json.ShouldNotContain("jsonPath"); + } + + // The seed applies ONLY to an unauthored tag (no topic AND no jsonPath) — a brand-new tag emits an + // explicit "$" once the operator touches anything, which is the guided half of the deal. + [Fact] + public void ToJson_seeds_the_root_jsonPath_on_a_fresh_tag() + { + var m = MqttTagConfigModel.FromJson(null); + m.Topic = "a/b"; + + m.ToJson().ShouldContain("\"jsonPath\":\"$\""); + } - // Falsifiability control for the test above: the SAME blob with a jsonPath must validate clean, so - // the failure above is provably the missing jsonPath and not the topic or the dataType. [Fact] public void Validate_JsonWithPath_ReturnsNull() => MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Json","jsonPath":"$","dataType":"Float64"}""") From 033b3700c4d5f275574cbb9b1c44de1dcdecdbb7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 17:49:14 -0400 Subject: [PATCH 23/43] test(mqtt): Mosquitto TLS+auth fixture + env-gated plain live suite Adds the MQTT driver integration-test project: an eclipse-mosquitto fixture running auth AND TLS (allow_anonymous false on both listeners), a mosquitto_pub-based JSON publisher emitting retained messages, a cert/password generator script whose output is gitignored, and a live suite gated on MQTT_FIXTURE_ENDPOINT that skips clean offline. The fixture is never anonymous by design: an anonymous broker would let the driver's TLS/CA-pin and auth paths go untested, and those fail silently. The CA-pin leg carries its own falsifiability control (a foreign CA generated in-process must be rejected). Live gate on 10.100.0.35: 7/7 passed. It found a driver defect, reported not fixed here (src/ is out of this task's scope): MqttConnection.ConnectAsync RETURNS NORMALLY when Mosquitto rejects a CONNECT as not-authorized -- MQTTnet 5 does not throw on an unsuccessful CONNACK, so a wrong broker password yields DriverState.Healthy from InitializeAsync. The auth-negative test therefore asserts the invariant that holds either way (no session is ever established) and documents the finding. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- ZB.MOM.WW.OtOpcUa.slnx | 1 + .../Docker/.gitignore | 14 + .../Docker/docker-compose.yml | 77 +++ .../Docker/gen-fixture-material.sh | 137 ++++ .../Docker/mosquitto.conf | 65 ++ .../Docker/publisher/publish.sh | 67 ++ .../MqttFixture.cs | 205 ++++++ .../PlainMqttLiveTests.cs | 616 ++++++++++++++++++ ...tOpcUa.Driver.Mqtt.IntegrationTests.csproj | 49 ++ 9 files changed, 1231 insertions(+) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/.gitignore create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/docker-compose.yml create mode 100755 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/gen-fixture-material.sh create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/mosquitto.conf create mode 100755 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/publisher/publish.sh create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/MqttFixture.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/PlainMqttLiveTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests.csproj diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 02a8700d..7c5147f0 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -108,6 +108,7 @@ + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/.gitignore b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/.gitignore new file mode 100644 index 00000000..b69c261a --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/.gitignore @@ -0,0 +1,14 @@ +# Fixture credential + TLS material produced by gen-fixture-material.sh. +# +# NEVER commit anything in here: it holds the broker password hash, the fixture CA's +# PRIVATE key and the server's private key. The generator script is the artifact in +# git; its output is regenerated per machine. +secrets/ + +# Belt-and-braces: catch key/cert material written directly into this directory by a +# hand-run openssl command that forgot the secrets/ prefix. +*.key +*.crt +*.csr +*.srl +passwd diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/docker-compose.yml b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/docker-compose.yml new file mode 100644 index 00000000..a1ecfec7 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/docker-compose.yml @@ -0,0 +1,77 @@ +# MQTT integration-test fixture — Eclipse Mosquitto (auth + TLS) + a JSON publisher. +# +# PREREQUISITE — run this first, in this directory: +# +# MQTT_FIXTURE_PASSWORD='' ./gen-fixture-material.sh +# +# It writes ./secrets/ (password file + CA + server certificate/key), which is +# gitignored and which both services below mount. Without it the broker will not +# start: there is no anonymous fallback, by design. +# +# Bring up (on the shared Docker host — see infra/README.md §1): +# +# ssh dohertj2@10.100.0.35 'cd /opt/otopcua-mqtt \ +# && MQTT_FIXTURE_USERNAME=otopcua MQTT_FIXTURE_PASSWORD= docker compose up -d' +# +# Endpoints: 10.100.0.35:8883 (TLS + auth) · 10.100.0.35:1883 (plaintext + auth, smoke) +# +# Unlike the Modbus / S7 fixtures there are no compose profiles: both services are +# always wanted together (a broker with nothing publishing into it tests nothing), and +# they bind different ports so nothing contends. +services: + mosquitto: + image: eclipse-mosquitto:2.0.22 + container_name: otopcua-mosquitto + restart: unless-stopped + # Every lmxopcua fixture container carries this so the shared Docker host stays + # discoverable with `docker ps --filter label=project=lmxopcua`. + labels: + project: lmxopcua + ports: + - "1883:1883" + - "8883:8883" + volumes: + - ./mosquitto.conf:/mosquitto/config/mosquitto.conf:ro + - ./secrets:/mosquitto/secrets:ro + healthcheck: + # Authenticated round-trip against the broker's own $SYS tree ($$ escapes the + # compose variable syntax). Proves the password file loaded and the listener + # accepts a real session — a bare port check would go green on a broker that + # rejects every CONNECT. + test: + - CMD-SHELL + - >- + mosquitto_sub -h 127.0.0.1 -p 1883 + -u "$$MQTT_FIXTURE_USERNAME" -P "$$MQTT_FIXTURE_PASSWORD" + -t '$$SYS/broker/uptime' -C 1 -W 3 + interval: 5s + timeout: 8s + retries: 12 + start_period: 5s + environment: + # Consumed only by the healthcheck above; the broker itself reads the hashed + # password file. Supplied by the operator's environment at `docker compose up` — + # never defaulted here, so a missing value fails loudly instead of silently + # provisioning a known-password broker. + MQTT_FIXTURE_USERNAME: ${MQTT_FIXTURE_USERNAME:?set MQTT_FIXTURE_USERNAME (must match gen-fixture-material.sh)} + MQTT_FIXTURE_PASSWORD: ${MQTT_FIXTURE_PASSWORD:?set MQTT_FIXTURE_PASSWORD (must match gen-fixture-material.sh)} + + publisher: + image: eclipse-mosquitto:2.0.22 + container_name: otopcua-mqtt-publisher + restart: unless-stopped + labels: + project: lmxopcua + depends_on: + mosquitto: + condition: service_healthy + volumes: + - ./publisher/publish.sh:/publish.sh:ro + environment: + BROKER_HOST: mosquitto + BROKER_PORT: "1883" + TOPIC_PREFIX: ${MQTT_FIXTURE_TOPIC_PREFIX:-otopcua/fixture} + PUBLISH_INTERVAL: ${MQTT_FIXTURE_PUBLISH_INTERVAL:-2} + MQTT_FIXTURE_USERNAME: ${MQTT_FIXTURE_USERNAME:?set MQTT_FIXTURE_USERNAME} + MQTT_FIXTURE_PASSWORD: ${MQTT_FIXTURE_PASSWORD:?set MQTT_FIXTURE_PASSWORD} + entrypoint: ["/bin/sh", "/publish.sh"] diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/gen-fixture-material.sh b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/gen-fixture-material.sh new file mode 100755 index 00000000..3b72538f --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/gen-fixture-material.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env sh +# --------------------------------------------------------------------------- +# Generates the MQTT fixture's password file + TLS material into ./secrets/. +# +# NOTHING this script writes is committed: ./secrets/ is excluded by the sibling +# .gitignore. The SCRIPT is the artifact in git, the OUTPUT never is. Re-run it on any +# machine that needs the fixture (or on the Docker host); the material is throwaway by +# construction — a self-issued CA, a 2048-bit server key, and whatever password the +# operator supplies in the environment. +# +# Usage: +# MQTT_FIXTURE_PASSWORD='' ./gen-fixture-material.sh +# +# Environment: +# MQTT_FIXTURE_PASSWORD (required) broker password. No default — a committed or +# defaulted password is exactly what the plan's +# "secrets from env, never committed" rule forbids. +# MQTT_FIXTURE_USERNAME broker username (default: otopcua) +# MQTT_FIXTURE_SAN_IPS comma-separated IP SANs for the server certificate +# (default: 10.100.0.35,127.0.0.1) +# MQTT_FIXTURE_SAN_DNS comma-separated DNS SANs +# (default: localhost,mosquitto) +# MQTT_FIXTURE_CERT_CN server certificate CN (default: first IP SAN) +# MQTT_FIXTURE_CERT_DAYS certificate lifetime in days (default: 825) +# MQTT_FIXTURE_SECRETS_DIR output directory (default: