diff --git a/docs/drivers/Mqtt.md b/docs/drivers/Mqtt.md index 632384b3..e546f315 100644 --- a/docs/drivers/Mqtt.md +++ b/docs/drivers/Mqtt.md @@ -48,24 +48,50 @@ Driver-type string: **`Mqtt`** (`DriverTypeNames.Mqtt`). ## Configuration Bound through `MqttJson.Options` — **case-insensitive**, enums by **name**, unknown members skipped. -Author the connection on the **device** (`DeviceConfig`) and the mode on the **driver** -(`DriverConfig`); `DriverDeviceConfigMerger` merges them, and injects `rawTags`. + +**The whole connection is authored on the DRIVER** (`DriverConfig`), via `MqttDriverForm` in the `/raw` +driver-config modal. Unlike Modbus / S7 / OPC UA Client, MQTT does **not** use the v3 +endpoint→`DeviceConfig` split: an MQTT driver instance holds exactly one broker session, so +`MqttDeviceForm` is informational (the same shape as `GalaxyDeviceForm`) and devices under an MQTT +driver are pure organisational grouping in the RawPath. + +> **Why not the device.** `DriverDeviceConfigMerger` merges a device's keys up to the top level *only +> when the driver has exactly one device*. A device-authored broker connection therefore disappears +> from the merged blob the moment a second device is added — a silent outage with nothing to see at +> deploy time. Driver-level authoring is correct for 0, 1 or N devices. A pre-form deployment that put +> the connection on a sole device still works (merge-up wins over the driver's keys) and +> `MqttDeviceForm` flags those keys so the mismatch is visible; move them to the driver. + +`DriverDeviceConfigMerger` still merges driver + device and injects `rawTags`. ```jsonc -// DriverConfig -{ "Mode": "Plain", "Plain": { "TopicPrefix": "", "DefaultQos": 1 } } - -// DeviceConfig +// DriverConfig — authored by MqttDriverForm. PascalCase keys, enums as names. { "Host": "10.100.0.35", "Port": 8883, "UseTls": true, + "AllowUntrustedServerCertificate": false, + "CaCertificatePath": null, // pins the chain; null = OS trust store "Username": "otopcua", - "Password": "", // supply via ${secret:} / env — never commit - "CaCertificatePath": null // pins the chain; null = OS trust store + "Password": "", // never commit + "ProtocolVersion": "V500", + "CleanSession": true, + "KeepAliveSeconds": 30, + "ConnectTimeoutSeconds": 15, + "ReconnectMinBackoffSeconds": 1, + "ReconnectMaxBackoffSeconds": 30, + "MaxPayloadBytes": 1048576, + "Mode": "Plain", + "Plain": { "TopicPrefix": "", "DefaultQos": 1 } } + +// DeviceConfig — empty; MQTT has no per-device endpoint. +{} ``` +The form never writes `RawTags` (the deploy artifact owns it) and never touches `Sparkplug` (P2) — an +existing `Sparkplug` object, and any key a newer driver adds, survive a load→save untouched. + Every key has a default, so nothing is strictly required by the binder. Notable ones: `port` **8883**, `useTls` **true**, `protocolVersion` **V500**, `cleanSession` **true**, `keepAliveSeconds` **30**, `connectTimeoutSeconds` **15**, @@ -77,7 +103,13 @@ Every key has a default, so nothing is strictly required by the binder. Notable > other forever — the broker logs `Client already connected, closing old connection` and both > nodes reconnect every few seconds while still reporting `Healthy`. Unset (the default) lets MQTTnet > generate a unique id per connection, which is correct. This was observed live during the P1 gate. -> (The probe and browser already self-uniquify with `-probe-` / `-browse-` suffixes.) +> (The probe and browser already self-uniquify with `-probe-` / `-browse-` suffixes.) `MqttDriverForm` +> defaults the field blank and raises this warning inline the moment a value is typed. + +The form validates every knob against the driver's own `[Range]` bounds (port 1–65535, keep-alive / +connect-timeout / backoffs ≥ 1 s, max-backoff ≥ min-backoff, `maxPayloadBytes` ≥ 1, QoS 0–2) and +additionally **clamps** on serialize, so an operator who ignores the inline error still cannot persist +a `connectTimeoutSeconds: 0` driver-brick. ## Tag Configuration diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/MqttDeviceForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/MqttDeviceForm.razor new file mode 100644 index 00000000..c44627be --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/MqttDeviceForm.razor @@ -0,0 +1,61 @@ +@* MQTT device form — informational only. An MQTT driver instance holds exactly one broker session, + configured on the DRIVER (MqttDriverForm), so there is no per-device connection endpoint to author; + devices are pure organisational grouping in the RawPath. The DeviceConfig is round-tripped verbatim, + and Test-connect uses the driver's broker settings via the merged config. Same shape as + GalaxyDeviceForm. + + The warning below fires only for a pre-form deployment that authored the connection on the device: + DriverDeviceConfigMerger merges a SOLE device's keys up over the driver's, so those keys keep + winning over what the driver form shows. *@ +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms + +
+
Connection
+
+ MQTT connects to a single broker configured on the driver — there is no per-device + endpoint to author here. Edit host / port / TLS / credentials on the driver's config. Devices under an + MQTT driver exist purely to group tags in the RawPath. +
+
+ +@if (_model.LegacyConnectionKeys.Count > 0) +{ +
+
Legacy connection keys on this device
+
+

+ This device's config still carries broker connection keys from before the driver form existed: + @string.Join(", ", _model.LegacyConnectionKeys). +

+

+ While this driver has exactly one device these keys are merged up and + override the driver form's values — so the driver form may not show what is + actually in effect. They are preserved (nothing is dropped), but the durable fix is to author the + connection on the driver and clear these keys: a second device on this driver stops the merge-up + entirely and the connection would vanish. +

+
+
+} + +@code { + /// The device's DeviceConfig JSON (round-tripped verbatim — MQTT has no per-device endpoint). + [Parameter] public string DeviceConfigJson { get; set; } = "{}"; + /// Fired when the (preserved) DeviceConfig changes — no-op UI, kept for the modal contract. + [Parameter] public EventCallback DeviceConfigJsonChanged { get; set; } + + private MqttDeviceModel _model = MqttDeviceModel.FromJson(null); + private string? _lastParsed; + + protected override void OnParametersSet() + { + if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal)) + { + _model = MqttDeviceModel.FromJson(DeviceConfigJson); + _lastParsed = DeviceConfigJson; + } + } + + /// Serialises the (preserved) DeviceConfig JSON. + public string GetConfigJson() => _model.ToJson(); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/MqttDeviceModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/MqttDeviceModel.cs new file mode 100644 index 00000000..c49d0656 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/MqttDeviceModel.cs @@ -0,0 +1,73 @@ +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms; + +/// +/// Device model for the MQTT / Sparkplug B driver. An MQTT driver instance holds exactly one +/// broker session, configured on the driver (MqttDriverForm) — so, like Galaxy, there +/// is no per-device connection endpoint to author and this model round-trips the DeviceConfig +/// JSON verbatim. Devices under an MQTT driver are pure organisational grouping in the RawPath. +/// +/// +/// +/// Why the connection is not authored here. DriverDeviceConfigMerger merges a device's +/// keys up to the top level only when the driver has exactly one device. A broker connection +/// authored on the device would therefore disappear from the merged blob the moment an operator adds +/// a second device — a silent, deploy-time-invisible outage. Driver-level authoring is correct for +/// 0, 1 or N devices. +/// +/// +/// exists because that merge-up still happens. A +/// pre-form deployment (hand-authored or SQL-seeded, as the P1 live gate had to do) may carry +/// Host/Port/… on a sole device's DeviceConfig, and those keys keep winning over +/// the driver form's values. They are preserved (never silently dropped) and reported so the device +/// form can tell the operator why the driver form's host is not the one in effect. +/// +/// +public sealed class MqttDeviceModel +{ + /// + /// MqttDriverOptions connection-surface key names that a legacy DeviceConfig may + /// carry and that would merge up over the driver form's values. + /// + private static readonly string[] ConnectionKeyNames = + [ + "Host", "Port", "ClientId", "UseTls", "AllowUntrustedServerCertificate", + "CaCertificatePath", "Username", "Password", "ProtocolVersion", "CleanSession", + "KeepAliveSeconds", "ConnectTimeoutSeconds", "ReconnectMinBackoffSeconds", + "ReconnectMaxBackoffSeconds", + ]; + + private System.Text.Json.Nodes.JsonObject _bag = new(); + + /// + /// The connection keys this DeviceConfig actually carries, in the casing they were + /// authored with — empty for the normal case. Non-empty means a sole-device merge-up will + /// override the driver form for those keys. + /// + public IReadOnlyList LegacyConnectionKeys { get; private set; } = []; + + /// Loads a model from a DeviceConfig JSON string, retaining every original key. + /// The raw DeviceConfig JSON string, or null for a new/empty device. + /// The populated . + public static MqttDeviceModel FromJson(string? json) + { + var bag = TagConfigJson.ParseOrNew(json); + return new MqttDeviceModel + { + _bag = bag, + LegacyConnectionKeys = bag + .Select(p => p.Key) + .Where(k => ConnectionKeyNames.Contains(k, StringComparer.OrdinalIgnoreCase)) + .ToList(), + }; + } + + /// Serialises the (preserved) DeviceConfig back to a JSON string. + /// The serialised DeviceConfig JSON string. + public string ToJson() => TagConfigJson.Serialize(_bag); + + /// Validation hook; MQTT device rows carry no endpoint, so always valid. + /// null — no per-device endpoint to validate. + public string? Validate() => null; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor index a13dfce4..8d8d851e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor @@ -65,6 +65,9 @@ case DriverTypeNames.Galaxy: break; + case DriverTypeNames.Mqtt: + + break; default:
No typed device form for driver type @_driverType.
break; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor index d2140099..2f8d3bc0 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor @@ -57,14 +57,30 @@ case DriverTypeNames.Galaxy: break; + case DriverTypeNames.Mqtt: + + break; default:
No typed config form for driver type @_driverType.
break; } -

- The connection endpoint + Test-connect live on the driver's device — open the device modal to author host/port and verify connectivity. -

+ @* Galaxy + MQTT hold ONE connection per driver instance, so they author it here and + their device forms are informational. Every other driver splits the endpoint onto + the device (the v3 endpoint→DeviceConfig split). *@ + @if (_driverType is DriverTypeNames.Galaxy or DriverTypeNames.Mqtt) + { +

+ This driver holds a single connection, authored above. Test-connect lives on its + device — open the device modal to verify connectivity. +

+ } + else + { +

+ The connection endpoint + Test-connect live on the driver's device — open the device modal to author host/port and verify connectivity. +

+ } @if (_saveError is not null) { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MqttDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MqttDriverForm.razor new file mode 100644 index 00000000..8349837c --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MqttDriverForm.razor @@ -0,0 +1,274 @@ +@* Embeddable MQTT / Sparkplug B driver config form body. Hosted by DriverConfigModal. + + UNLIKE Modbus / S7 / OPC UA Client, the connection is authored HERE, not on the device: an MQTT + driver instance holds exactly ONE broker session, so MqttDeviceForm is informational (the Galaxy + precedent). DriverDeviceConfigMerger only merges a device's keys up when the driver has exactly one + device, so a device-authored broker connection would silently vanish the moment a second device + exists. All parsing/serialisation goes through the ONE shared MqttJson.Options instance, via the + pure MqttDriverFormModel — enums must round-trip by NAME (this repo's systemic driver-enum bug). + + The Sparkplug sub-object (groupId / hostId / actAsPrimaryHost / requestRebirthOnGap / + birthObservationWindowSeconds) is P2 (Task 21+): the placeholder below mirrors how MqttTagConfigEditor + stubs its Sparkplug branch, and any existing sparkplug keys are preserved untouched. *@ +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers +@using ZB.MOM.WW.OtOpcUa.Driver.Mqtt + +@* Broker connection *@ +
+
Broker connection
+
+
+
+ + +
Broker hostname or IP. The connection lives on the driver — MQTT has one broker session per driver.
+
+
+ + +
8883 for TLS, 1883 for plaintext.
+
+
+ + +
Blank lets the client generate a unique id per connection. That is the correct setting.
+
+ @if (!string.IsNullOrWhiteSpace(_form.ClientId)) + { +
+ +
+ } +
+
+
+ +@* Transport security *@ +
+
Transport security
+
+
+
+
+ + +
+
Default on. Must match the broker's listener.
+
+
+
+ + +
+
Accepts any self-signed broker cert.
+
+
+ + +
PEM file pinning the broker's chain. Blank uses the OS trust store.
+
+ @if (_form.UseTls && _form.AllowUntrustedServerCertificate) + { +
+ +
+ } + @if (!_form.UseTls) + { +
+ +
+ } +
+
+
+ +@* Authentication *@ +
+
Authentication
+
+
+
+ + +
Blank connects without credentials.
+
+
+ + +
Stored in the driver config and carried in the deployment artifact. Never written to a log.
+
+
+
+
+ +@* Session *@ +
+
Session
+
+
+
+ + + @foreach (var v in Enum.GetValues()) + { + + } + +
Default V500 (MQTT 5.0).
+
+
+
+ + +
+
+
+ + +
Default 30 s.
+
+
+ + +
Default 15 s. Must be at least 1 — also bounds Test connect.
+
+
+ + +
Default 1 s.
+
+
+ + +
Default 30 s. Caps the exponential backoff.
+
+
+
+
+ +@* Ingest *@ +
+
Ingest
+
+
+
+ + + @foreach (var v in Enum.GetValues()) + { + + } + +
Plain = topic-bound tags. Sparkplug B is not implemented yet.
+
+
+ + +
Default 1048576 (1 MiB). A larger message is refused before decode.
+
+ + @if (_form.Mode == MqttMode.Plain) + { +
+ + +
Scopes browse/discovery. Per-tag topics are authored explicitly and unaffected.
+
+
+ + @* A plain select, not InputSelect: InputSelect natively parses string + enum only, + and QoS is an int. Same control shape MqttTagConfigEditor uses for per-tag QoS. *@ + +
Applied when a tag's own QoS is unset.
+
+ } + else + { +
+ +
+ } +
+
+
+ +@if (_validationError is not null) +{ +
@_validationError
+} + + + +@code { + /// The driver-level DriverConfig JSON (broker connection + ingest mode). + [Parameter] public string DriverConfigJson { get; set; } = "{}"; + /// Fired whenever a field changes — enables @bind-DriverConfigJson. + [Parameter] public EventCallback DriverConfigJsonChanged { get; set; } + /// The per-instance resilience-pipeline overrides JSON, or null. + [Parameter] public string? ResilienceConfig { get; set; } + /// Fired when the resilience overrides change. + [Parameter] public EventCallback ResilienceConfigChanged { get; set; } + + private MqttDriverFormModel _form = MqttDriverFormModel.FromJson(null); + private string? _lastParsed; + private string? _validationError; + + protected override void OnParametersSet() + { + // Re-parse only when the inbound value actually changed, so an unrelated parent re-render + // cannot clobber an in-progress edit. + if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal)) + { + _form = MqttDriverFormModel.FromJson(DriverConfigJson); + _validationError = _form.Validate(); + _lastParsed = DriverConfigJson; + } + } + + /// Serialises the current config to DriverConfig JSON (PascalCase keys, enums as names). + public string GetConfigJson() => _form.ToJson(); + + // TryParse so a bad/empty change value can never throw into the Blazor circuit — it falls back to + // the driver's own default QoS rather than poisoning the config. + private Task OnDefaultQosChangedAsync(object? value) + { + _form.DefaultQos = int.TryParse(value?.ToString(), out var qos) ? qos : 1; + return EmitAsync(); + } + + private async Task EmitAsync() + { + _validationError = _form.Validate(); + var json = GetConfigJson(); + _lastParsed = json; + await DriverConfigJsonChanged.InvokeAsync(json); + } + + private async Task OnResilienceChanged(string? r) + { + ResilienceConfig = r; + await ResilienceConfigChanged.InvokeAsync(r); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MqttDriverFormModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MqttDriverFormModel.cs new file mode 100644 index 00000000..820ce797 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MqttDriverFormModel.cs @@ -0,0 +1,296 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; +using ZB.MOM.WW.OtOpcUa.Driver.Mqtt; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms; + +/// +/// Typed working model behind MqttDriverForm — the MQTT driver's whole broker-connection and +/// ingest surface, as authored in the /raw driver-config modal. Kept as a plain class (not +/// nested in the razor) so every rule here is unit-testable; the razor is a thin shell over it. +/// +/// +/// +/// The connection lives on the DRIVER, not the device. MQTT holds exactly one broker session +/// per driver instance, so MqttDeviceForm is informational (the Galaxy precedent) and this +/// form owns host/port/TLS/credentials. Authoring them on the device would work only while the +/// driver has exactly ONE device — DriverDeviceConfigMerger merges a sole device's keys up to +/// the top level and stops doing so the moment a second device exists, at which point the broker +/// connection would silently vanish from the merged blob. +/// +/// +/// One JSON instance, always . Every MQTT config seam — the +/// runtime factory, the Test-connect probe, the driver's re-parse, the address-picker browser and +/// now this form — parses and serialises through that single shared instance. Divergent per-seam +/// options are this repo's documented systemic driver-enum bug: a form that wrote +/// "mode": 1 would be accepted by a seam carrying a JsonStringEnumConverter and fault +/// the one that does not, so "Test connect" goes green and the deployed driver dies. Consequently +/// the emitted keys are PascalCase (the shared instance carries no camelCase naming policy) +/// and enum values are names. +/// +/// +/// Preserving what this form does not author. The inbound blob's keys are retained in a bag +/// and the typed fields are written over it, so (a) the Sparkplug sub-object — P2, Task 21+ — +/// survives a load→save untouched, and (b) any key a newer driver adds is not silently dropped by an +/// older AdminUI. RawTags is the one key deliberately REMOVED: the deploy artifact injects it +/// via DriverDeviceConfigMerger, and a form-authored copy would be dead weight. +/// +/// +public sealed class MqttDriverFormModel +{ + /// + /// The property name the deploy artifact owns; never authored + /// here and stripped from the emitted blob. + /// + private const string RawTagsKey = nameof(MqttDriverOptions.RawTags); + + /// + /// The property name for the P2 Sparkplug sub-object; never + /// authored here and preserved verbatim from the inbound blob. + /// + private const string SparkplugKey = nameof(MqttDriverOptions.Sparkplug); + + // --- Broker connection ---------------------------------------------------------------------- + + /// Broker hostname or IP address. + public string Host { get; set; } = "localhost"; + + /// Broker TCP port (8883 TLS / 1883 plaintext by convention). + public int Port { get; set; } = 8883; + + /// + /// MQTT client identifier sent at CONNECT. Blank is the correct default — see + /// . + /// + public string ClientId { get; set; } = ""; + + // --- Transport security --------------------------------------------------------------------- + + /// Connect over TLS. Defaults true; this driver ships no plaintext-by-default posture. + public bool UseTls { get; set; } = true; + + /// Accept any self-signed / untrusted broker certificate. Dev / on-prem escape hatch only. + public bool AllowUntrustedServerCertificate { get; set; } + + /// PEM CA file pinning the broker's TLS chain; blank uses the OS trust store. + public string CaCertificatePath { get; set; } = ""; + + // --- Authentication ------------------------------------------------------------------------- + + /// Username for broker authentication; blank connects without one. + public string Username { get; set; } = ""; + + /// + /// Password for broker authentication. Stored in the driver's config blob and carried in the + /// deployment artifact — the same plaintext-round-trip posture the OPC UA Client driver form + /// uses. Never logged: redacts it in its own member printer. + /// + public string Password { get; set; } = ""; + + // --- Session -------------------------------------------------------------------------------- + + /// MQTT protocol version negotiated at CONNECT. + public MqttProtocolVersion ProtocolVersion { get; set; } = MqttProtocolVersion.V500; + + /// Request a clean session (v3.1.1) / clean start (v5.0) at CONNECT. + public bool CleanSession { get; set; } = true; + + /// Keep-alive interval, in seconds, sent at CONNECT. + public int KeepAliveSeconds { get; set; } = 30; + + /// Bounded connect deadline, in seconds — the driver never hangs past this. + public int ConnectTimeoutSeconds { get; set; } = 15; + + /// Initial reconnect backoff, in seconds, after a connection drop. + public int ReconnectMinBackoffSeconds { get; set; } = 1; + + /// Cap on the exponential reconnect backoff, in seconds. + public int ReconnectMaxBackoffSeconds { get; set; } = 30; + + // --- Ingest --------------------------------------------------------------------------------- + + /// Selects the ingest shape — Plain topics or Sparkplug B (the latter is P2). + public MqttMode Mode { get; set; } = MqttMode.Plain; + + /// Ceiling on an inbound message body, in bytes; a larger message is refused before decode. + public int MaxPayloadBytes { get; set; } = 1024 * 1024; + + /// Plain mode: optional prefix used when the driver composes a topic (e.g. browse scoping). + public string TopicPrefix { get; set; } = ""; + + /// Plain mode: default subscription QoS applied when a tag's own qos is unset. + public int DefaultQos { get; set; } = 1; + + /// + /// The inbound blob's keys, retained so the P2 Sparkplug sub-object and any key a newer + /// driver adds survive a load→save. + /// + private JsonObject _bag = new(); + + /// + /// The operator-facing consequence of pinning , surfaced by the form + /// whenever it is non-blank. Observed live during the P1 gate: MQTT requires client ids to be + /// unique per broker, and both nodes of a redundant pair run the driver — so a fixed id makes + /// them evict each other in a few-second reconnect loop while both still report Healthy. + /// + public const string ClientIdPairWarning = + "Both nodes of a redundant pair run this driver. MQTT client ids must be unique per broker, so a " + + "fixed client id makes the two nodes evict each other forever — they reconnect every few seconds " + + "while both still report Healthy. Leave this blank unless exactly one node will ever connect."; + + /// + /// Loads a model from a DriverConfig JSON string. Typed values are bound through + /// (the same instance the runtime factory uses, so what this form + /// shows is what the driver would see); the raw key bag is retained alongside for preservation. + /// Never throws — a blank/malformed blob degrades to the driver's own defaults. + /// + /// The raw DriverConfig JSON string, or null for a new driver. + /// The populated . + public static MqttDriverFormModel FromJson(string? json) + { + var bag = TagConfigJson.ParseOrNew(json); + var o = TryDeserialize(json) ?? new MqttDriverOptions(); + + return new MqttDriverFormModel + { + Host = o.Host, + Port = o.Port, + ClientId = o.ClientId ?? "", + UseTls = o.UseTls, + AllowUntrustedServerCertificate = o.AllowUntrustedServerCertificate, + CaCertificatePath = o.CaCertificatePath ?? "", + Username = o.Username ?? "", + Password = o.Password ?? "", + ProtocolVersion = o.ProtocolVersion, + CleanSession = o.CleanSession, + KeepAliveSeconds = o.KeepAliveSeconds, + ConnectTimeoutSeconds = o.ConnectTimeoutSeconds, + ReconnectMinBackoffSeconds = o.ReconnectMinBackoffSeconds, + ReconnectMaxBackoffSeconds = o.ReconnectMaxBackoffSeconds, + Mode = o.Mode, + MaxPayloadBytes = o.MaxPayloadBytes, + TopicPrefix = o.Plain?.TopicPrefix ?? "", + DefaultQos = o.Plain?.DefaultQos ?? new MqttPlainOptions().DefaultQos, + _bag = bag, + }; + } + + /// + /// Builds the typed options this form authors. Every numeric knob is clamped into the + /// range declares, so an operator who ignores + /// and saves anyway still cannot persist a driver-bricking blob — a + /// connectTimeoutSeconds: 0 is exactly the operator-authorable brick this repo has hit + /// before. stays null and + /// stays empty; both are handled by . + /// + /// The clamped, driver-legal options record. + public MqttDriverOptions ToOptions() => new() + { + Host = Host.Trim(), + Port = Math.Clamp(Port, 1, 65535), + ClientId = Blank(ClientId), + UseTls = UseTls, + AllowUntrustedServerCertificate = AllowUntrustedServerCertificate, + CaCertificatePath = Blank(CaCertificatePath), + Username = Blank(Username), + Password = Password, + ProtocolVersion = ProtocolVersion, + CleanSession = CleanSession, + KeepAliveSeconds = Math.Max(1, KeepAliveSeconds), + ConnectTimeoutSeconds = Math.Max(1, ConnectTimeoutSeconds), + ReconnectMinBackoffSeconds = Math.Max(1, ReconnectMinBackoffSeconds), + ReconnectMaxBackoffSeconds = Math.Max(1, ReconnectMaxBackoffSeconds), + Mode = Mode, + MaxPayloadBytes = Math.Max(1, MaxPayloadBytes), + Plain = new MqttPlainOptions + { + TopicPrefix = TopicPrefix.Trim(), + DefaultQos = Math.Clamp(DefaultQos, 0, 2), + }, + }; + + /// + /// Serialises the authored fields over the preserved key bag and returns the DriverConfig + /// JSON. Keys are PascalCase and enums are names, because the serialisation runs through + /// — see the type remarks. Sparkplug is left exactly as the + /// inbound blob had it (P2 authors it) and RawTags is removed (the deploy artifact owns it). + /// + /// The serialised DriverConfig JSON string. + public string ToJson() + { + var typed = JsonSerializer.SerializeToNode(ToOptions(), MqttJson.Options)!.AsObject(); + + // Never let this form's (always-null / always-empty) placeholders overwrite what it does not own. + typed.Remove(SparkplugKey); + typed.Remove(RawTagsKey); + + foreach (var (key, value) in typed.ToList()) + { + // Case-insensitive replace: MqttJson.Options binds a hand-edited camelCase blob happily, so + // the bag may hold "host" while we emit "Host" — writing both would leave two keys fighting. + RemoveIgnoringCase(_bag, key); + _bag[key] = value?.DeepClone(); + } + + RemoveIgnoringCase(_bag, RawTagsKey); + return TagConfigJson.Serialize(_bag); + } + + /// + /// Client-side validation surfaced inline by the form. Returns the first error, or null + /// when the model is valid. Every bound mirrors a [Range] on + /// (or, for QoS, on ), so the + /// authoring surface accepts exactly what the driver accepts. + /// + /// An error message describing the validation failure, or null when valid. + public string? Validate() + { + if (string.IsNullOrWhiteSpace(Host)) { return "A broker host is required."; } + if (Port is < 1 or > 65535) { return "Port must be between 1 and 65535."; } + if (KeepAliveSeconds < 1) { return "Keep-alive must be at least 1 second."; } + if (ConnectTimeoutSeconds < 1) + { + return "Connect timeout must be at least 1 second — 0 would make every connect attempt " + + "expire instantly and the driver would never come up."; + } + if (ReconnectMinBackoffSeconds < 1) { return "Minimum reconnect backoff must be at least 1 second."; } + if (ReconnectMaxBackoffSeconds < 1) { return "Maximum reconnect backoff must be at least 1 second."; } + if (ReconnectMaxBackoffSeconds < ReconnectMinBackoffSeconds) + { + return "Maximum reconnect backoff must not be below the minimum."; + } + if (MaxPayloadBytes < 1) { return "Max payload bytes must be at least 1."; } + if (DefaultQos is < 0 or > 2) { return "Default QoS must be 0, 1 or 2."; } + return null; + } + + /// Blank ⇒ null so the key is omitted and the driver's own default applies. + /// The raw form value. + /// The trimmed value, or null when blank. + private static string? Blank(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + /// Removes every key matching case-insensitively. + /// The object to mutate. + /// The key name to remove. + private static void RemoveIgnoringCase(JsonObject o, string name) + { + foreach (var key in o.Select(p => p.Key) + .Where(k => string.Equals(k, name, StringComparison.OrdinalIgnoreCase)) + .ToList()) + { + o.Remove(key); + } + } + + /// Binds the blob through the shared options; null on blank/malformed input. + /// The raw DriverConfig JSON. + /// The bound options, or null. + private static MqttDriverOptions? TryDeserialize(string? json) + { + if (string.IsNullOrWhiteSpace(json)) { return null; } + try { return JsonSerializer.Deserialize(json, MqttJson.Options); } + catch (JsonException) { return null; } + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DriverPageJsonConverterTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DriverPageJsonConverterTests.cs index 2b05a9af..31221eb7 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DriverPageJsonConverterTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DriverPageJsonConverterTests.cs @@ -4,6 +4,7 @@ using System.Text.Json.Serialization; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms; +using ZB.MOM.WW.OtOpcUa.Driver.Mqtt; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; @@ -23,15 +24,45 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; /// public sealed class DriverPageJsonConverterTests { - /// Every concrete *DriverForm in the AdminUI assembly that declares a - /// _jsonOpts config serializer. + /// + /// Driver forms whose config serializer does NOT live in a private static _jsonOpts field + /// on the form, mapped to the options instance they actually use. Each entry is an explicit, + /// reviewed exception — the guard below still asserts the converter on whatever instance is + /// named here, so this narrows where the options live, never whether they are + /// checked. + /// MqttDriverForm: the MQTT driver deliberately keeps one + /// for all five of its config seams (runtime factory, + /// Test-connect probe, driver re-parse, address-picker browser, this form) in + /// MqttJson.Options — a per-form copy would be a fifth divergent instance, i.e. exactly + /// the bug this whole file guards. The form itself is a shell over the pure + /// MqttDriverFormModel, which serialises through that shared instance. + /// + private static IReadOnlyDictionary ExternalConfigSerializers { get; } = + new Dictionary + { + [typeof(MqttDriverForm)] = MqttJson.Options, + }; + + /// Every concrete *DriverForm in the AdminUI assembly. private static IReadOnlyList DriverFormTypes { get; } = typeof(ModbusDriverForm).Assembly.GetTypes() .Where(t => t.Name.EndsWith("DriverForm", StringComparison.Ordinal) && !t.IsAbstract) - .Where(t => t.GetField("_jsonOpts", BindingFlags.NonPublic | BindingFlags.Static) is not null) .OrderBy(t => t.Name) .ToList(); + /// Resolves a form's config serializer — its own _jsonOpts field, else the + /// explicitly-registered external instance — or null when it has neither. + /// A driver form component type. + /// The options the form serialises config through, or null. + private static JsonSerializerOptions? ResolveConfigSerializer(Type formType) + { + if (formType.GetField("_jsonOpts", BindingFlags.NonPublic | BindingFlags.Static) is { } field) + { + return (JsonSerializerOptions?)field.GetValue(null); + } + return ExternalConfigSerializers.GetValueOrDefault(formType); + } + /// xUnit theory source over the driver-form types discovered by reflection. public static TheoryData DriverFormsWithJsonOpts() { @@ -48,24 +79,28 @@ public sealed class DriverPageJsonConverterTests [MemberData(nameof(DriverFormsWithJsonOpts))] public void Driver_form_json_options_register_string_enum_converter(Type formType) { - var field = formType.GetField("_jsonOpts", BindingFlags.NonPublic | BindingFlags.Static); - var opts = (JsonSerializerOptions)field!.GetValue(null)!; + var opts = ResolveConfigSerializer(formType); + opts.ShouldNotBeNull( + $"{formType.Name} must serialise config through a private static _jsonOpts field, or be listed in " + + "ExternalConfigSerializers with the shared options instance it uses — otherwise its enum handling " + + "is unguarded."); opts.Converters.OfType().ShouldNotBeEmpty( - $"{formType.Name}._jsonOpts must register a JsonStringEnumConverter; otherwise AdminUI-authored " + - "enum config fields serialise as numbers and the string-typed driver factory throws on parse."); + $"{formType.Name}'s config serializer must register a JsonStringEnumConverter; otherwise " + + "AdminUI-authored enum config fields serialise as numbers and the string-typed driver factory " + + "throws on parse."); } /// Enforces that EVERY concrete *DriverForm routes config serialization through a - /// _jsonOpts field — otherwise a new form that serialised config a different way would slip - /// past the converter guard above. Also a floor check so a rename can't silently shrink the set. + /// serializer the guard above can reach — otherwise a new form that serialised config a different way + /// would slip past it. Also a floor check so a rename can't silently shrink the set. [Fact] public void Every_driver_form_uses_a_guarded_jsonOpts_serializer() { - var allDriverForms = typeof(ModbusDriverForm).Assembly.GetTypes() - .Where(t => t.Name.EndsWith("DriverForm", StringComparison.Ordinal) && !t.IsAbstract) - .ToList(); - allDriverForms.Count.ShouldBeGreaterThanOrEqualTo(8, "reflection should discover the full driver-form fleet"); - DriverFormTypes.Count.ShouldBe(allDriverForms.Count, - "every *DriverForm must declare a _jsonOpts config serializer so the string-enum converter guard covers it"); + DriverFormTypes.Count.ShouldBeGreaterThanOrEqualTo(9, "reflection should discover the full driver-form fleet"); + + var unguarded = DriverFormTypes.Where(t => ResolveConfigSerializer(t) is null).Select(t => t.Name).ToList(); + unguarded.ShouldBeEmpty( + "every *DriverForm must expose its config serializer (a _jsonOpts field, or an ExternalConfigSerializers " + + "entry) so the string-enum converter guard covers it"); } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MqttDeviceModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MqttDeviceModelTests.cs new file mode 100644 index 00000000..5c43d2c7 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MqttDeviceModelTests.cs @@ -0,0 +1,54 @@ +using System.Text.Json.Nodes; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// Guards for the MQTT device model. MQTT holds ONE broker connection per driver, so — like +/// Galaxy — the device carries no endpoint of its own and the model round-trips its DeviceConfig +/// verbatim. The one behaviour it does add is detecting legacy connection keys left on a +/// DeviceConfig by a pre-form (hand-authored / SQL-seeded) deployment, because +/// DriverDeviceConfigMerger merges a sole device's keys UP over the driver's and they would +/// otherwise silently win over what the driver form shows. +/// +public sealed class MqttDeviceModelTests +{ + [Fact] + public void Round_trips_every_key_verbatim() + { + const string inbound = """{"Host":"10.100.0.35","Port":8883,"anything":{"nested":true}}"""; + + var json = MqttDeviceModel.FromJson(inbound).ToJson(); + var o = (JsonObject)JsonNode.Parse(json)!; + + o["Host"]!.GetValue().ShouldBe("10.100.0.35"); + o["Port"]!.GetValue().ShouldBe(8883); + o["anything"]!["nested"]!.GetValue().ShouldBeTrue(); + } + + [Fact] + public void A_new_device_serializes_to_an_empty_object() + => MqttDeviceModel.FromJson(null).ToJson().ShouldBe("{}"); + + [Fact] + public void A_malformed_blob_degrades_to_an_empty_object_rather_than_throwing() + => MqttDeviceModel.FromJson("}{ not json").ToJson().ShouldBe("{}"); + + [Fact] + public void Validate_always_passes_because_there_is_no_per_device_endpoint() + => MqttDeviceModel.FromJson("""{"Host":"x"}""").Validate().ShouldBeNull(); + + [Fact] + public void Legacy_connection_keys_are_reported_case_insensitively() + { + var model = MqttDeviceModel.FromJson("""{"host":"h","PORT":1883,"useTls":false,"unrelated":1}"""); + + model.LegacyConnectionKeys.ShouldBe(["host", "PORT", "useTls"], ignoreOrder: true); + } + + [Fact] + public void An_empty_device_reports_no_legacy_connection_keys() + => MqttDeviceModel.FromJson("{}").LegacyConnectionKeys.ShouldBeEmpty(); +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MqttDriverFormModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MqttDriverFormModelTests.cs new file mode 100644 index 00000000..5c5e1bd3 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MqttDriverFormModelTests.cs @@ -0,0 +1,329 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms; +using ZB.MOM.WW.OtOpcUa.Commons.Types; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Mqtt; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// Round-trip, enum-serialization and validation guards for the MQTT driver config form model. +/// +/// The load-bearing assertion is : +/// it deserializes this form's output through — the exact instance +/// MqttDriverFactoryExtensions.CreateInstance and MqttDriverProbe use — so an +/// AdminUI-authored blob that Test-connect accepts cannot fault the deployed driver. That is this +/// repo's documented systemic driver-enum-serialization bug, reproduced as a guard. +/// +/// +public sealed class MqttDriverFormModelTests +{ + private static JsonObject Parse(string json) => (JsonObject)JsonNode.Parse(json)!; + + private static bool HasKeyIgnoringCase(JsonObject o, string name) + => o.Any(p => string.Equals(p.Key, name, StringComparison.OrdinalIgnoreCase)); + + // --- defaults ------------------------------------------------------------------------------- + + [Fact] + public void Defaults_match_the_driver_options_secure_posture() + { + var model = MqttDriverFormModel.FromJson(null); + var driverDefaults = new MqttDriverOptions(); + + model.Port.ShouldBe(driverDefaults.Port); + model.UseTls.ShouldBeTrue(); + model.UseTls.ShouldBe(driverDefaults.UseTls); + model.AllowUntrustedServerCertificate.ShouldBeFalse(); + model.AllowUntrustedServerCertificate.ShouldBe(driverDefaults.AllowUntrustedServerCertificate); + model.ProtocolVersion.ShouldBe(driverDefaults.ProtocolVersion); + model.Mode.ShouldBe(driverDefaults.Mode); + model.MaxPayloadBytes.ShouldBe(driverDefaults.MaxPayloadBytes); + + // The redundancy-pair eviction guard: an unauthored driver leaves clientId unset so MQTTnet + // generates a unique id per connection (P1 live-gate finding). + model.ClientId.ShouldBe(""); + } + + // --- round-trip ----------------------------------------------------------------------------- + + [Fact] + public void Round_trip_preserves_every_authored_field() + { + var model = MqttDriverFormModel.FromJson(null); + model.Host = "broker.internal"; + model.Port = 1883; + model.ClientId = "otopcua-site-a"; + model.Username = "otopcua"; + model.Password = "s3cret"; + model.UseTls = false; + model.AllowUntrustedServerCertificate = true; + model.CaCertificatePath = "/etc/ssl/ca.pem"; + model.ProtocolVersion = MqttProtocolVersion.V311; + model.CleanSession = false; + model.KeepAliveSeconds = 45; + model.ConnectTimeoutSeconds = 9; + model.ReconnectMinBackoffSeconds = 2; + model.ReconnectMaxBackoffSeconds = 60; + model.Mode = MqttMode.Plain; + model.MaxPayloadBytes = 4096; + model.TopicPrefix = "otopcua/fixture/"; + model.DefaultQos = 2; + + var back = MqttDriverFormModel.FromJson(model.ToJson()); + + back.Host.ShouldBe("broker.internal"); + back.Port.ShouldBe(1883); + back.ClientId.ShouldBe("otopcua-site-a"); + back.Username.ShouldBe("otopcua"); + back.Password.ShouldBe("s3cret"); + back.UseTls.ShouldBeFalse(); + back.AllowUntrustedServerCertificate.ShouldBeTrue(); + back.CaCertificatePath.ShouldBe("/etc/ssl/ca.pem"); + back.ProtocolVersion.ShouldBe(MqttProtocolVersion.V311); + back.CleanSession.ShouldBeFalse(); + back.KeepAliveSeconds.ShouldBe(45); + back.ConnectTimeoutSeconds.ShouldBe(9); + back.ReconnectMinBackoffSeconds.ShouldBe(2); + back.ReconnectMaxBackoffSeconds.ShouldBe(60); + back.Mode.ShouldBe(MqttMode.Plain); + back.MaxPayloadBytes.ShouldBe(4096); + back.TopicPrefix.ShouldBe("otopcua/fixture/"); + back.DefaultQos.ShouldBe(2); + } + + // --- the factory-parity guard (the point of the whole file) --------------------------------- + + [Fact] + public void Serialized_blob_binds_onto_MqttDriverOptions_through_the_shared_MqttJson_options() + { + var model = MqttDriverFormModel.FromJson(null); + model.Host = "10.100.0.35"; + model.Port = 8883; + model.Username = "otopcua"; + model.Password = "pw"; + model.ProtocolVersion = MqttProtocolVersion.V311; + model.Mode = MqttMode.SparkplugB; + model.ConnectTimeoutSeconds = 7; + model.DefaultQos = 0; + + // The exact instance the runtime factory + probe + driver + browser parse through. + var options = JsonSerializer.Deserialize(model.ToJson(), MqttJson.Options); + + options.ShouldNotBeNull(); + options.Host.ShouldBe("10.100.0.35"); + options.Port.ShouldBe(8883); + options.Username.ShouldBe("otopcua"); + options.Password.ShouldBe("pw"); + options.ProtocolVersion.ShouldBe(MqttProtocolVersion.V311); + options.Mode.ShouldBe(MqttMode.SparkplugB); + options.ConnectTimeoutSeconds.ShouldBe(7); + options.Plain.ShouldNotBeNull(); + options.Plain.DefaultQos.ShouldBe(0); + } + + [Fact] + public void Enums_serialize_as_names_never_as_ordinals() + { + var model = MqttDriverFormModel.FromJson(null); + model.Mode = MqttMode.SparkplugB; + model.ProtocolVersion = MqttProtocolVersion.V311; + + var json = model.ToJson(); + + json.ShouldContain("\"Mode\":\"SparkplugB\""); + json.ShouldContain("\"ProtocolVersion\":\"V311\""); + // The historical bug: a numerically-serialized enum that the string-typed runtime binder faults on. + json.ShouldNotContain("\"Mode\":1", Case.Sensitive); + json.ShouldNotContain("\"ProtocolVersion\":0", Case.Sensitive); + } + + [Fact] + public void An_ordinal_only_reader_cannot_bind_the_blob_which_proves_the_enums_are_names() + { + // The string-match-free half of the enum pin, and a falsifiability control for the test above. + // A JsonSerializerOptions WITHOUT a JsonStringEnumConverter can read a NUMERIC enum but not a + // named one — so if this form ever regressed to writing ordinals (the historical driver bug), + // this deserialize would start SUCCEEDING and the test would fail. Deliberately independent of + // MqttJson.Options, because a round-trip through one shared instance is symmetric and would + // happily pass on ordinals at both ends. + var model = MqttDriverFormModel.FromJson(null); + model.Mode = MqttMode.SparkplugB; + model.ProtocolVersion = MqttProtocolVersion.V311; + + var ordinalOnlyReader = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + + Should.Throw( + () => JsonSerializer.Deserialize(model.ToJson(), ordinalOnlyReader)); + } + + // --- blob hygiene --------------------------------------------------------------------------- + + [Fact] + public void RawTags_are_never_written_into_the_driver_config() + { + // The deploy artifact injects RawTags via DriverDeviceConfigMerger; a form-authored copy would + // be dead weight at best and a stale shadow at worst. + const string inbound = """{"Host":"h","rawTags":[{"RawPath":"a/b","TagConfig":"{}"}]}"""; + + var json = MqttDriverFormModel.FromJson(inbound).ToJson(); + + HasKeyIgnoringCase(Parse(json), "RawTags").ShouldBeFalse(); + } + + [Fact] + public void Unknown_keys_and_the_sparkplug_subobject_survive_a_load_save() + { + const string inbound = """ + {"Host":"h","sparkplug":{"GroupId":"G1","HostId":"H1","ActAsPrimaryHost":true},"aFutureKey":42} + """; + + var json = MqttDriverFormModel.FromJson(inbound).ToJson(); + var o = Parse(json); + + // Sparkplug is P2 (Task 21+) — this form never authors it, and must never drop it. + o["sparkplug"].ShouldNotBeNull(); + o["sparkplug"]!["GroupId"]!.GetValue().ShouldBe("G1"); + o["sparkplug"]!["ActAsPrimaryHost"]!.GetValue().ShouldBeTrue(); + o["aFutureKey"]!.GetValue().ShouldBe(42); + } + + [Fact] + public void A_camelCase_inbound_key_is_replaced_not_duplicated() + { + // MqttJson.Options is PropertyNameCaseInsensitive, so a hand-edited camelCase blob binds; the + // form must overwrite that key rather than leave "host" and "Host" fighting in one object. + const string inbound = """{"host":"old","port":1111}"""; + + var model = MqttDriverFormModel.FromJson(inbound); + model.Host.ShouldBe("old"); + model.Port.ShouldBe(1111); + model.Host = "new"; + + var o = Parse(model.ToJson()); + + o.Count(p => string.Equals(p.Key, "Host", StringComparison.OrdinalIgnoreCase)).ShouldBe(1); + JsonSerializer.Deserialize(o.ToJsonString(), MqttJson.Options)!.Host.ShouldBe("new"); + } + + [Fact] + public void Blank_optional_strings_are_omitted_so_the_driver_defaults_apply() + { + var json = MqttDriverFormModel.FromJson(null).ToJson(); + var options = JsonSerializer.Deserialize(json, MqttJson.Options)!; + + options.ClientId.ShouldBeNull(); + options.Username.ShouldBeNull(); + options.CaCertificatePath.ShouldBeNull(); + } + + [Fact] + public void The_merged_deploy_blob_keeps_the_form_connection_and_gains_the_artifact_RawTags() + { + // The real deploy seam: DriverDeviceConfigMerger folds this form's DriverConfig together with the + // (empty) MQTT DeviceConfig and injects the authored raw tags. MQTT authors its connection on the + // DRIVER precisely so this survives 0, 1 or N devices. + var model = MqttDriverFormModel.FromJson(null); + model.Host = "broker.internal"; + model.Port = 1883; + model.UseTls = false; + model.Mode = MqttMode.Plain; + model.DefaultQos = 2; + + var rawTags = new[] + { + new RawTagEntry("Plant/Mqtt/Dev1/Temp", """{"topic":"a/b"}""", WriteIdempotent: false, DeviceName: "Dev1"), + }; + + foreach (var devices in new[] + { + Array.Empty(), + [new DriverDeviceConfigMerger.DeviceRow("Dev1", "{}")], + [new DriverDeviceConfigMerger.DeviceRow("Dev1", "{}"), new DriverDeviceConfigMerger.DeviceRow("Dev2", "{}")], + }) + { + var merged = DriverDeviceConfigMerger.Merge(model.ToJson(), devices, rawTags); + var options = JsonSerializer.Deserialize(merged, MqttJson.Options)!; + + options.Host.ShouldBe("broker.internal", $"device count {devices.Length}"); + options.Port.ShouldBe(1883, $"device count {devices.Length}"); + options.UseTls.ShouldBeFalse($"device count {devices.Length}"); + options.Plain!.DefaultQos.ShouldBe(2, $"device count {devices.Length}"); + options.RawTags.Count.ShouldBe(1, $"device count {devices.Length}"); + options.RawTags[0].RawPath.ShouldBe("Plant/Mqtt/Dev1/Temp"); + } + } + + // --- validation ----------------------------------------------------------------------------- + + [Fact] + public void Validate_accepts_a_default_model() + => MqttDriverFormModel.FromJson(null).Validate().ShouldBeNull(); + + [Theory] + [InlineData("Host", "")] + [InlineData("Port", 0)] + [InlineData("Port", 70000)] + [InlineData("KeepAliveSeconds", 0)] + [InlineData("ConnectTimeoutSeconds", 0)] + [InlineData("ReconnectMinBackoffSeconds", 0)] + [InlineData("ReconnectMaxBackoffSeconds", -1)] + [InlineData("MaxPayloadBytes", 0)] + [InlineData("DefaultQos", 3)] + [InlineData("DefaultQos", -1)] + public void Validate_rejects_an_out_of_range_field(string field, object value) + { + var model = MqttDriverFormModel.FromJson(null); + typeof(MqttDriverFormModel).GetProperty(field)!.SetValue(model, value); + + model.Validate().ShouldNotBeNull(); + } + + [Fact] + public void Validate_rejects_a_max_backoff_below_the_min() + { + var model = MqttDriverFormModel.FromJson(null); + model.ReconnectMinBackoffSeconds = 30; + model.ReconnectMaxBackoffSeconds = 5; + + model.Validate().ShouldNotBeNull(); + } + + [Fact] + public void An_ignored_validation_error_still_cannot_produce_a_driver_bricking_blob() + { + // Documented finding: `timeoutSeconds: 0` was an operator-authorable driver-brick. Validate() + // reports it; the serializer clamps it, so even a saved-anyway blob stays inside the driver's + // own [Range] bounds. + var model = MqttDriverFormModel.FromJson(null); + model.ConnectTimeoutSeconds = 0; + model.KeepAliveSeconds = -5; + model.ReconnectMinBackoffSeconds = 0; + model.ReconnectMaxBackoffSeconds = -3; + model.MaxPayloadBytes = 0; + model.Port = 99999; + model.DefaultQos = 9; + + var options = JsonSerializer.Deserialize(model.ToJson(), MqttJson.Options)!; + + options.ConnectTimeoutSeconds.ShouldBeGreaterThanOrEqualTo(1); + options.KeepAliveSeconds.ShouldBeGreaterThanOrEqualTo(1); + options.ReconnectMinBackoffSeconds.ShouldBeGreaterThanOrEqualTo(1); + options.ReconnectMaxBackoffSeconds.ShouldBeGreaterThanOrEqualTo(1); + options.MaxPayloadBytes.ShouldBeGreaterThanOrEqualTo(1); + options.Port.ShouldBeInRange(1, 65535); + options.Plain!.DefaultQos.ShouldBeInRange(0, 2); + } + + [Fact] + public void A_malformed_inbound_blob_degrades_to_defaults_rather_than_throwing() + { + var model = MqttDriverFormModel.FromJson("}{ not json"); + + model.Host.ShouldBe(new MqttDriverOptions().Host); + model.UseTls.ShouldBeTrue(); + } +}