using System.ComponentModel.DataAnnotations; using System.Text; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; 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; /// /// 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. /// public MqttSparkplugOptions? Sparkplug { get; init; } /// /// Plain-mode-only settings. Populated when is /// ; 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(", 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; } } /// /// 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. /// /// /// Implemented by SparkplugPrimaryHost (Gitea #511) on a dedicated broker connection /// whose lifetime is the claim, and gated on the redundancy Primary role so the two nodes /// of a pair never both claim. Requires ; set without one, the driver warns /// and declines rather than publishing to a malformed topic. /// 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; }