feat(mqtt): Contracts options DTO + enums (name-serialized)

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
This commit is contained in:
Joseph Doherty
2026-07-24 13:53:06 -04:00
parent d677ac7ad3
commit f22db5d801
8 changed files with 264 additions and 4 deletions
@@ -0,0 +1,152 @@
using System.ComponentModel.DataAnnotations;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
/// <summary>
/// MQTT / Sparkplug B driver configuration. Bound from <c>DriverConfig</c> JSON at
/// driver-host registration time. Models the settings documented in
/// <c>docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md</c> §5.1.
/// </summary>
/// <remarks>
/// A record (not a plain class) so a future secret-resolution seam can produce a
/// credential-resolved copy with a <c>with</c> expression — mirrors
/// <c>OpcUaClientDriverOptions</c>. <see cref="Mode"/> selects the ingest shape;
/// <see cref="Sparkplug"/> and <see cref="Plain"/> are nullable sub-objects — only the one
/// matching <see cref="Mode"/> is populated.
/// </remarks>
public sealed record MqttDriverOptions
{
/// <summary>Broker hostname or IP address.</summary>
public string Host { get; init; } = "localhost";
/// <summary>Broker TCP port.</summary>
[Range(1, 65535)]
public int Port { get; init; } = 8883;
/// <summary>
/// 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).
/// </summary>
public string? ClientId { get; init; }
/// <summary>
/// When <c>true</c>, connect over TLS. Default <c>true</c> — this driver never ships an
/// anonymous/plaintext-by-default posture; a deployment must opt into <c>false</c> for an
/// on-prem/dev broker with no TLS listener.
/// </summary>
public bool UseTls { get; init; } = true;
/// <summary>
/// When <c>true</c>, accept any self-signed / untrusted broker certificate. Dev/on-prem
/// escape hatch only — mirrors the <c>ServerHistorian</c> TLS knobs. Must stay
/// <c>false</c> in production so MITM attacks against the broker connection fail closed.
/// </summary>
public bool AllowUntrustedServerCertificate { get; init; } = false;
/// <summary>
/// PEM CA file pinning the broker's TLS chain. <c>null</c>/empty uses the OS trust
/// store.
/// </summary>
public string? CaCertificatePath { get; init; }
/// <summary>Username for broker authentication. <c>null</c> connects without a username.</summary>
public string? Username { get; init; }
/// <summary>
/// Password for broker authentication. Blank in committed JSON — supply via env, never
/// commit or log.
/// </summary>
public string? Password { get; init; } = "";
/// <summary>MQTT protocol version to negotiate at CONNECT.</summary>
public MqttProtocolVersion ProtocolVersion { get; init; } = MqttProtocolVersion.V500;
/// <summary>Whether to request a clean session (v3.1.1) / clean start (v5.0) at CONNECT.</summary>
public bool CleanSession { get; init; } = true;
/// <summary>Keep-alive interval sent at CONNECT.</summary>
[Range(1, int.MaxValue)]
public int KeepAliveSeconds { get; init; } = 30;
/// <summary>Bounded connect deadline (see design §8) — the driver never hangs past this.</summary>
[Range(1, int.MaxValue)]
public int ConnectTimeoutSeconds { get; init; } = 15;
/// <summary>Initial reconnect backoff after a connection drop.</summary>
[Range(1, int.MaxValue)]
public int ReconnectMinBackoffSeconds { get; init; } = 1;
/// <summary>Cap on the exponential reconnect backoff.</summary>
[Range(1, int.MaxValue)]
public int ReconnectMaxBackoffSeconds { get; init; } = 30;
/// <summary>Selects the ingest shape — Plain topics or Sparkplug B.</summary>
public MqttMode Mode { get; init; } = MqttMode.Plain;
/// <summary>
/// Sparkplug-only settings. Populated when <see cref="Mode"/> is
/// <see cref="MqttMode.SparkplugB"/>; <c>null</c> in Plain mode.
/// </summary>
public MqttSparkplugOptions? Sparkplug { get; init; }
/// <summary>
/// Plain-mode-only settings. Populated when <see cref="Mode"/> is
/// <see cref="MqttMode.Plain"/>; <c>null</c> in Sparkplug B mode.
/// </summary>
public MqttPlainOptions? Plain { get; init; }
}
/// <summary>
/// Sparkplug B settings for an <see cref="MqttDriverOptions"/> instance in
/// <see cref="MqttMode.SparkplugB"/> mode. See
/// <c>docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md</c> §5.1.
/// </summary>
public sealed record MqttSparkplugOptions
{
/// <summary>Sparkplug group id — the driver subscribes <c>spBv1.0/{GroupId}/#</c>.</summary>
public string GroupId { get; init; } = "";
/// <summary>
/// Sparkplug Host Application identity — published as <c>spBv1.0/STATE/{HostId}</c>
/// (Sparkplug v3.0).
/// </summary>
public string HostId { get; init; } = "";
/// <summary>
/// When <c>true</c>, this driver instance acts as the Sparkplug Primary Host
/// Application. At most one primary host may exist per host-id per broker.
/// </summary>
public bool ActAsPrimaryHost { get; init; } = false;
/// <summary>
/// When <c>true</c>, a detected sequence-number gap triggers a Sparkplug rebirth
/// request (NCMD) rather than silently continuing with stale metric state.
/// </summary>
public bool RequestRebirthOnGap { get; init; } = true;
/// <summary>
/// How long, in seconds, browse/discovery collects NBIRTH/DBIRTH traffic before
/// considering the observed metric set stable.
/// </summary>
[Range(1, int.MaxValue)]
public int BirthObservationWindowSeconds { get; init; } = 15;
}
/// <summary>
/// Plain-MQTT-mode settings for an <see cref="MqttDriverOptions"/> instance in
/// <see cref="MqttMode.Plain"/> mode. See
/// <c>docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md</c> §5.1.
/// </summary>
public sealed record MqttPlainOptions
{
/// <summary>
/// Optional prefix prepended when the driver needs to compose a topic (e.g. discovery
/// scoping); per-tag topics are authored explicitly and are unaffected.
/// </summary>
public string TopicPrefix { get; init; } = "";
/// <summary>Default QoS used when a tag's own <c>qos</c> is unset.</summary>
[Range(0, 2)]
public int DefaultQos { get; init; } = 1;
}
@@ -0,0 +1,14 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
/// <summary>
/// Selects the ingest shape an <see cref="MqttDriverOptions"/> instance uses. See
/// <c>docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md</c> §5.1.
/// </summary>
public enum MqttMode
{
/// <summary>Plain MQTT — the driver subscribes to authored topics directly.</summary>
Plain,
/// <summary>Sparkplug B — the driver decodes Tahu-encoded NBIRTH/DBIRTH/NDATA/DDATA payloads.</summary>
SparkplugB,
}
@@ -0,0 +1,17 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
/// <summary>
/// How a Plain-mode MQTT tag's payload is decoded. See
/// <c>docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md</c> §5.3.
/// </summary>
public enum MqttPayloadFormat
{
/// <summary>The payload is a JSON document; a JSONPath selects the value (<c>jsonPath</c>).</summary>
Json,
/// <summary>The payload bytes are used as-is (e.g. binary / opaque).</summary>
Raw,
/// <summary>The payload is a bare scalar string (e.g. <c>"23.5"</c>), parsed by <c>dataType</c>.</summary>
Scalar,
}
@@ -0,0 +1,14 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
/// <summary>
/// MQTT wire protocol version negotiated at CONNECT. See
/// <c>docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md</c> §5.1.
/// </summary>
public enum MqttProtocolVersion
{
/// <summary>MQTT 3.1.1.</summary>
V311,
/// <summary>MQTT 5.0.</summary>
V500,
}
@@ -6,9 +6,6 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<!-- TEMPORARY (Task 0 spike): proves the MQTTnet-5 graph restores + builds on net10.0
under this repo's central package management. `.Contracts` is transport-free by
design — Task 1 removes this reference and it moves to the `.Driver` project. -->
<PackageReference Include="MQTTnet" />
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
</Project>