From f22db5d8015924c42a569c168a1a23403b068d59 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 13:53:06 -0400 Subject: [PATCH] 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 + + + + + + + +