feat(mqtt): MqttDriverForm + MqttDeviceForm — the driver/device config forms P1 omitted

The P1 live gate had to insert MQTT driver config directly via SQL: DriverConfigModal and
DeviceModal both fell through to "No typed config form for driver type Mqtt" with no raw-JSON
fallback, so broker host/port/TLS/credentials were unauthorable from the AdminUI. Task 12 built
the *tag* editor; these are the driver + device surfaces.

MqttDriverForm authors the whole MqttDriverOptions connection surface (host/port/clientId,
TLS + CA pin, credentials, protocol version/clean-session/keep-alive, connect timeout, reconnect
backoffs, mode, maxPayloadBytes, and the Plain sub-object). The Sparkplug sub-object stays P2 —
a marked placeholder mirroring MqttTagConfigEditor's stub, with any existing sparkplug keys
preserved untouched.

The connection is authored on the DRIVER, not the device — unlike Modbus/S7/OpcUaClient and
contrary to what docs/drivers/Mqtt.md said. DriverDeviceConfigMerger merges a device's keys up
only when the driver has exactly ONE device, so a device-authored broker connection vanishes
silently the moment a second device is added. MqttDeviceForm is therefore informational (the
GalaxyDeviceForm shape) and round-trips DeviceConfig verbatim; it flags legacy connection keys
left on a device by a pre-form deployment, because those still win via merge-up.

Serialization goes through the ONE shared MqttJson.Options instance (Task 9's decision), never a
fifth per-form copy — pinned by an assertion that an ordinal-only reader CANNOT bind the emitted
blob, and by extending the fleet-wide DriverPageJsonConverterTests guard to resolve a form's
serializer from an explicit external-instance registry when it has no _jsonOpts field. Dropping
the converter from MqttJson.Options reddens 3 tests and leaves the symmetric round-trip green —
the Task 9 lesson, reproduced.

RawTags is stripped from the emitted blob (the deploy artifact owns it) and unknown top-level
keys survive a load->save. Validation mirrors the driver's own [Range] bounds inline, and
ToOptions() additionally clamps, so an ignored error still cannot persist a
connectTimeoutSeconds:0 driver-brick. A non-blank clientId raises the P1 live-gate warning
inline (fixed ids make redundant pair nodes evict each other while both report Healthy).

AdminUI 761/761 green (was 730), TWAE-clean; Driver.Mqtt 266/266 green.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 20:41:48 -04:00
parent 07a4a5ff5e
commit 92ba120964
10 changed files with 1200 additions and 27 deletions
@@ -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
<section class="panel notice rise" style="animation-delay:.04s">
<div class="panel-head">Connection</div>
<div style="padding:1rem" class="text-muted">
MQTT connects to a single broker configured on the <strong>driver</strong> — 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.
</div>
</section>
@if (_model.LegacyConnectionKeys.Count > 0)
{
<section class="panel rise mt-3" style="animation-delay:.06s;border-color:var(--alert)">
<div class="panel-head">Legacy connection keys on this device</div>
<div style="padding:1rem">
<p class="mb-2 small">
This device's config still carries broker connection keys from before the driver form existed:
<span class="mono">@string.Join(", ", _model.LegacyConnectionKeys)</span>.
</p>
<p class="mb-0 small">
While this driver has <strong>exactly one</strong> device these keys are merged up and
<strong>override</strong> 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.
</p>
</div>
</section>
}
@code {
/// <summary>The device's DeviceConfig JSON (round-tripped verbatim — MQTT has no per-device endpoint).</summary>
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
/// <summary>Fired when the (preserved) DeviceConfig changes — no-op UI, kept for the modal contract.</summary>
[Parameter] public EventCallback<string> 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;
}
}
/// <summary>Serialises the (preserved) DeviceConfig JSON.</summary>
public string GetConfigJson() => _model.ToJson();
}
@@ -0,0 +1,73 @@
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
/// <summary>
/// Device model for the MQTT / Sparkplug B driver. An MQTT driver instance holds exactly <b>one</b>
/// broker session, configured on the <b>driver</b> (<c>MqttDriverForm</c>) — so, like Galaxy, there
/// is no per-device connection endpoint to author and this model round-trips the <c>DeviceConfig</c>
/// JSON verbatim. Devices under an MQTT driver are pure organisational grouping in the RawPath.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why the connection is not authored here.</b> <c>DriverDeviceConfigMerger</c> merges a device's
/// keys up to the top level <i>only when the driver has exactly one device</i>. 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.
/// </para>
/// <para>
/// <b><see cref="LegacyConnectionKeys"/> exists because that merge-up still happens.</b> A
/// pre-form deployment (hand-authored or SQL-seeded, as the P1 live gate had to do) may carry
/// <c>Host</c>/<c>Port</c>/… on a sole device's <c>DeviceConfig</c>, 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.
/// </para>
/// </remarks>
public sealed class MqttDeviceModel
{
/// <summary>
/// <c>MqttDriverOptions</c> connection-surface key names that a legacy <c>DeviceConfig</c> may
/// carry and that would merge up over the driver form's values.
/// </summary>
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();
/// <summary>
/// The connection keys this <c>DeviceConfig</c> 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.
/// </summary>
public IReadOnlyList<string> LegacyConnectionKeys { get; private set; } = [];
/// <summary>Loads a model from a <c>DeviceConfig</c> JSON string, retaining every original key.</summary>
/// <param name="json">The raw <c>DeviceConfig</c> JSON string, or <c>null</c> for a new/empty device.</param>
/// <returns>The populated <see cref="MqttDeviceModel"/>.</returns>
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(),
};
}
/// <summary>Serialises the (preserved) <c>DeviceConfig</c> back to a JSON string.</summary>
/// <returns>The serialised <c>DeviceConfig</c> JSON string.</returns>
public string ToJson() => TagConfigJson.Serialize(_bag);
/// <summary>Validation hook; MQTT device rows carry no endpoint, so always valid.</summary>
/// <returns><c>null</c> — no per-device endpoint to validate.</returns>
public string? Validate() => null;
}
@@ -65,6 +65,9 @@
case DriverTypeNames.Galaxy:
<GalaxyDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
case DriverTypeNames.Mqtt:
<MqttDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
default:
<div class="alert alert-warning">No typed device form for driver type <span class="mono">@_driverType</span>.</div>
break;
@@ -57,14 +57,30 @@
case DriverTypeNames.Galaxy:
<GalaxyDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.Mqtt:
<MqttDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
default:
<div class="alert alert-warning">No typed config form for driver type <span class="mono">@_driverType</span>.</div>
break;
}
<p class="form-text mt-3 mb-0">
The connection endpoint + Test-connect live on the driver's <strong>device</strong> — open the device modal to author host/port and verify connectivity.
</p>
@* 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)
{
<p class="form-text mt-3 mb-0">
This driver holds a single connection, authored above. Test-connect lives on its
<strong>device</strong> — open the device modal to verify connectivity.
</p>
}
else
{
<p class="form-text mt-3 mb-0">
The connection endpoint + Test-connect live on the driver's <strong>device</strong> — open the device modal to author host/port and verify connectivity.
</p>
}
@if (_saveError is not null)
{
@@ -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 *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Broker connection</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-5">
<label class="form-label" for="mqtt-host">Host</label>
<InputText id="mqtt-host" @bind-Value="_form.Host" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="broker.internal" />
<div class="form-text">Broker hostname or IP. The connection lives on the driver — MQTT has one broker session per driver.</div>
</div>
<div class="col-md-3">
<label class="form-label" for="mqtt-port">Port</label>
<InputNumber id="mqtt-port" @bind-Value="_form.Port" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">8883 for TLS, 1883 for plaintext.</div>
</div>
<div class="col-md-4">
<label class="form-label" for="mqtt-client-id">Client ID <span class="badge bg-secondary">leave blank</span></label>
<InputText id="mqtt-client-id" @bind-Value="_form.ClientId" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="(auto-generated — recommended)" />
<div class="form-text">Blank lets the client generate a unique id per connection. That is the correct setting.</div>
</div>
@if (!string.IsNullOrWhiteSpace(_form.ClientId))
{
<div class="col-12">
<div class="alert alert-warning py-2 px-3 mb-0 small" role="alert">
<strong>Fixed client ID set.</strong> @MqttDriverFormModel.ClientIdPairWarning
</div>
</div>
}
</div>
</div>
</section>
@* Transport security *@
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Transport security</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.UseTls" @bind-Value:after="EmitAsync" class="form-check-input" id="mqtt-use-tls" />
<label class="form-check-label" for="mqtt-use-tls">Use TLS</label>
</div>
<div class="form-text mt-0">Default on. Must match the broker's listener.</div>
</div>
<div class="col-md-4">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.AllowUntrustedServerCertificate" @bind-Value:after="EmitAsync" class="form-check-input" id="mqtt-allow-untrusted" />
<label class="form-check-label" for="mqtt-allow-untrusted">
Allow untrusted server certificate <span class="badge bg-warning text-dark">Dev / on-prem only</span>
</label>
</div>
<div class="form-text mt-0">Accepts any self-signed broker cert.</div>
</div>
<div class="col-md-5">
<label class="form-label" for="mqtt-ca-path">CA certificate path (optional)</label>
<InputText id="mqtt-ca-path" @bind-Value="_form.CaCertificatePath" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="/etc/ssl/certs/broker-ca.pem" />
<div class="form-text">PEM file pinning the broker's chain. Blank uses the OS trust store.</div>
</div>
@if (_form.UseTls && _form.AllowUntrustedServerCertificate)
{
<div class="col-12">
<div class="alert alert-warning py-2 px-3 mb-0 small" role="alert">
<strong>Not safe for production.</strong> Accepting an untrusted broker certificate defeats
TLS server authentication — a machine-in-the-middle on the broker connection will succeed.
Pin the broker's chain with a CA certificate path instead.
</div>
</div>
}
@if (!_form.UseTls)
{
<div class="col-12">
<div class="alert alert-warning py-2 px-3 mb-0 small" role="alert">
<strong>TLS is off.</strong> The broker credentials below are sent in cleartext. Use a
plaintext listener only on a trusted on-prem segment.
</div>
</div>
}
</div>
</div>
</section>
@* Authentication *@
<section class="panel rise mt-3" style="animation-delay:.10s">
<div class="panel-head">Authentication</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label" for="mqtt-username">Username</label>
<InputText id="mqtt-username" @bind-Value="_form.Username" @bind-Value:after="EmitAsync" class="form-control form-control-sm" autocomplete="off" />
<div class="form-text">Blank connects without credentials.</div>
</div>
<div class="col-md-4">
<label class="form-label" for="mqtt-password">Password</label>
<InputText id="mqtt-password" @bind-Value="_form.Password" @bind-Value:after="EmitAsync" type="password" class="form-control form-control-sm" autocomplete="new-password" />
<div class="form-text">Stored in the driver config and carried in the deployment artifact. Never written to a log.</div>
</div>
</div>
</div>
</section>
@* Session *@
<section class="panel rise mt-3" style="animation-delay:.12s">
<div class="panel-head">Session</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label" for="mqtt-protocol-version">Protocol version</label>
<InputSelect id="mqtt-protocol-version" @bind-Value="_form.ProtocolVersion" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
@foreach (var v in Enum.GetValues<MqttProtocolVersion>())
{
<option value="@v">@v</option>
}
</InputSelect>
<div class="form-text">Default V500 (MQTT 5.0).</div>
</div>
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.CleanSession" @bind-Value:after="EmitAsync" class="form-check-input" id="mqtt-clean-session" />
<label class="form-check-label" for="mqtt-clean-session">Clean session / clean start</label>
</div>
</div>
<div class="col-md-3">
<label class="form-label" for="mqtt-keepalive">Keep-alive (seconds)</label>
<InputNumber id="mqtt-keepalive" @bind-Value="_form.KeepAliveSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 30 s.</div>
</div>
<div class="col-md-3">
<label class="form-label" for="mqtt-connect-timeout">Connect timeout (seconds)</label>
<InputNumber id="mqtt-connect-timeout" @bind-Value="_form.ConnectTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 15 s. Must be at least 1 — also bounds Test connect.</div>
</div>
<div class="col-md-3">
<label class="form-label" for="mqtt-backoff-min">Reconnect min backoff (seconds)</label>
<InputNumber id="mqtt-backoff-min" @bind-Value="_form.ReconnectMinBackoffSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 1 s.</div>
</div>
<div class="col-md-3">
<label class="form-label" for="mqtt-backoff-max">Reconnect max backoff (seconds)</label>
<InputNumber id="mqtt-backoff-max" @bind-Value="_form.ReconnectMaxBackoffSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 30 s. Caps the exponential backoff.</div>
</div>
</div>
</div>
</section>
@* Ingest *@
<section class="panel rise mt-3" style="animation-delay:.14s">
<div class="panel-head">Ingest</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label" for="mqtt-mode">Mode</label>
<InputSelect id="mqtt-mode" @bind-Value="_form.Mode" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
@foreach (var v in Enum.GetValues<MqttMode>())
{
<option value="@v">@v</option>
}
</InputSelect>
<div class="form-text">Plain = topic-bound tags. Sparkplug B is not implemented yet.</div>
</div>
<div class="col-md-3">
<label class="form-label" for="mqtt-max-payload">Max payload bytes</label>
<InputNumber id="mqtt-max-payload" @bind-Value="_form.MaxPayloadBytes" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 1048576 (1 MiB). A larger message is refused before decode.</div>
</div>
@if (_form.Mode == MqttMode.Plain)
{
<div class="col-md-3">
<label class="form-label" for="mqtt-topic-prefix">Topic prefix (optional)</label>
<InputText id="mqtt-topic-prefix" @bind-Value="_form.TopicPrefix" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="otopcua/fixture/" />
<div class="form-text">Scopes browse/discovery. Per-tag topics are authored explicitly and unaffected.</div>
</div>
<div class="col-md-3">
<label class="form-label" for="mqtt-default-qos">Default QoS</label>
@* A plain select, not InputSelect: InputSelect<T> natively parses string + enum only,
and QoS is an int. Same control shape MqttTagConfigEditor uses for per-tag QoS. *@
<select id="mqtt-default-qos" class="form-select form-select-sm" value="@_form.DefaultQos"
@onchange="@(e => OnDefaultQosChangedAsync(e.Value))">
<option value="0" selected="@(_form.DefaultQos == 0)">0 — at most once</option>
<option value="1" selected="@(_form.DefaultQos == 1)">1 — at least once</option>
<option value="2" selected="@(_form.DefaultQos == 2)">2 — exactly once</option>
</select>
<div class="form-text">Applied when a tag's own QoS is unset.</div>
</div>
}
else
{
<div class="col-12">
<div class="alert alert-info py-2 px-3 mb-0 small" role="alert">
Sparkplug B settings (group id, host id, primary-host role, rebirth-on-gap, birth
observation window) are not available yet — they land with Sparkplug ingest. Switch back
to <strong>Plain</strong> to author a topic-bound driver. Any existing Sparkplug keys on
this driver are preserved untouched.
</div>
</div>
}
</div>
</div>
</section>
@if (_validationError is not null)
{
<div class="panel notice mt-3" style="border-color:var(--alert)">@_validationError</div>
}
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
@code {
/// <summary>The driver-level DriverConfig JSON (broker connection + ingest mode).</summary>
[Parameter] public string DriverConfigJson { get; set; } = "{}";
/// <summary>Fired whenever a field changes — enables @bind-DriverConfigJson.</summary>
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
/// <summary>The per-instance resilience-pipeline overrides JSON, or null.</summary>
[Parameter] public string? ResilienceConfig { get; set; }
/// <summary>Fired when the resilience overrides change.</summary>
[Parameter] public EventCallback<string?> 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;
}
}
/// <summary>Serialises the current config to DriverConfig JSON (PascalCase keys, enums as names).</summary>
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);
}
}
@@ -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;
/// <summary>
/// Typed working model behind <c>MqttDriverForm</c> — the MQTT driver's whole broker-connection and
/// ingest surface, as authored in the <c>/raw</c> 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.
/// </summary>
/// <remarks>
/// <para>
/// <b>The connection lives on the DRIVER, not the device.</b> MQTT holds exactly one broker session
/// per driver instance, so <c>MqttDeviceForm</c> 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 — <c>DriverDeviceConfigMerger</c> 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.
/// </para>
/// <para>
/// <b>One JSON instance, always <see cref="MqttJson.Options"/>.</b> 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
/// <c>"mode": 1</c> would be accepted by a seam carrying a <c>JsonStringEnumConverter</c> and fault
/// the one that does not, so "Test connect" goes green and the deployed driver dies. Consequently
/// the emitted keys are <b>PascalCase</b> (the shared instance carries no camelCase naming policy)
/// and enum values are <b>names</b>.
/// </para>
/// <para>
/// <b>Preserving what this form does not author.</b> The inbound blob's keys are retained in a bag
/// and the typed fields are written over it, so (a) the <c>Sparkplug</c> 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. <c>RawTags</c> is the one key deliberately REMOVED: the deploy artifact injects it
/// via <c>DriverDeviceConfigMerger</c>, and a form-authored copy would be dead weight.
/// </para>
/// </remarks>
public sealed class MqttDriverFormModel
{
/// <summary>
/// The <see cref="MqttDriverOptions"/> property name the deploy artifact owns; never authored
/// here and stripped from the emitted blob.
/// </summary>
private const string RawTagsKey = nameof(MqttDriverOptions.RawTags);
/// <summary>
/// The <see cref="MqttDriverOptions"/> property name for the P2 Sparkplug sub-object; never
/// authored here and preserved verbatim from the inbound blob.
/// </summary>
private const string SparkplugKey = nameof(MqttDriverOptions.Sparkplug);
// --- Broker connection ----------------------------------------------------------------------
/// <summary>Broker hostname or IP address.</summary>
public string Host { get; set; } = "localhost";
/// <summary>Broker TCP port (8883 TLS / 1883 plaintext by convention).</summary>
public int Port { get; set; } = 8883;
/// <summary>
/// MQTT client identifier sent at CONNECT. <b>Blank is the correct default</b> — see
/// <see cref="ClientIdPairWarning"/>.
/// </summary>
public string ClientId { get; set; } = "";
// --- Transport security ---------------------------------------------------------------------
/// <summary>Connect over TLS. Defaults <c>true</c>; this driver ships no plaintext-by-default posture.</summary>
public bool UseTls { get; set; } = true;
/// <summary>Accept any self-signed / untrusted broker certificate. Dev / on-prem escape hatch only.</summary>
public bool AllowUntrustedServerCertificate { get; set; }
/// <summary>PEM CA file pinning the broker's TLS chain; blank uses the OS trust store.</summary>
public string CaCertificatePath { get; set; } = "";
// --- Authentication -------------------------------------------------------------------------
/// <summary>Username for broker authentication; blank connects without one.</summary>
public string Username { get; set; } = "";
/// <summary>
/// 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: <see cref="MqttDriverOptions"/> redacts it in its own member printer.
/// </summary>
public string Password { get; set; } = "";
// --- Session --------------------------------------------------------------------------------
/// <summary>MQTT protocol version negotiated at CONNECT.</summary>
public MqttProtocolVersion ProtocolVersion { get; set; } = MqttProtocolVersion.V500;
/// <summary>Request a clean session (v3.1.1) / clean start (v5.0) at CONNECT.</summary>
public bool CleanSession { get; set; } = true;
/// <summary>Keep-alive interval, in seconds, sent at CONNECT.</summary>
public int KeepAliveSeconds { get; set; } = 30;
/// <summary>Bounded connect deadline, in seconds — the driver never hangs past this.</summary>
public int ConnectTimeoutSeconds { get; set; } = 15;
/// <summary>Initial reconnect backoff, in seconds, after a connection drop.</summary>
public int ReconnectMinBackoffSeconds { get; set; } = 1;
/// <summary>Cap on the exponential reconnect backoff, in seconds.</summary>
public int ReconnectMaxBackoffSeconds { get; set; } = 30;
// --- Ingest ---------------------------------------------------------------------------------
/// <summary>Selects the ingest shape — Plain topics or Sparkplug B (the latter is P2).</summary>
public MqttMode Mode { get; set; } = MqttMode.Plain;
/// <summary>Ceiling on an inbound message body, in bytes; a larger message is refused before decode.</summary>
public int MaxPayloadBytes { get; set; } = 1024 * 1024;
/// <summary>Plain mode: optional prefix used when the driver composes a topic (e.g. browse scoping).</summary>
public string TopicPrefix { get; set; } = "";
/// <summary>Plain mode: default subscription QoS applied when a tag's own <c>qos</c> is unset.</summary>
public int DefaultQos { get; set; } = 1;
/// <summary>
/// The inbound blob's keys, retained so the P2 <c>Sparkplug</c> sub-object and any key a newer
/// driver adds survive a load→save.
/// </summary>
private JsonObject _bag = new();
/// <summary>
/// The operator-facing consequence of pinning <see cref="ClientId"/>, 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 <i>while both still report Healthy</i>.
/// </summary>
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.";
/// <summary>
/// Loads a model from a <c>DriverConfig</c> JSON string. Typed values are bound through
/// <see cref="MqttJson.Options"/> (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.
/// </summary>
/// <param name="json">The raw <c>DriverConfig</c> JSON string, or <c>null</c> for a new driver.</param>
/// <returns>The populated <see cref="MqttDriverFormModel"/>.</returns>
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,
};
}
/// <summary>
/// Builds the typed options this form authors. Every numeric knob is <b>clamped</b> into the
/// range <see cref="MqttDriverOptions"/> declares, so an operator who ignores
/// <see cref="Validate"/> and saves anyway still cannot persist a driver-bricking blob — a
/// <c>connectTimeoutSeconds: 0</c> is exactly the operator-authorable brick this repo has hit
/// before. <see cref="MqttDriverOptions.Sparkplug"/> stays <c>null</c> and
/// <see cref="MqttDriverOptions.RawTags"/> stays empty; both are handled by <see cref="ToJson"/>.
/// </summary>
/// <returns>The clamped, driver-legal options record.</returns>
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),
},
};
/// <summary>
/// Serialises the authored fields over the preserved key bag and returns the <c>DriverConfig</c>
/// JSON. Keys are PascalCase and enums are names, because the serialisation runs through
/// <see cref="MqttJson.Options"/> — see the type remarks. <c>Sparkplug</c> is left exactly as the
/// inbound blob had it (P2 authors it) and <c>RawTags</c> is removed (the deploy artifact owns it).
/// </summary>
/// <returns>The serialised <c>DriverConfig</c> JSON string.</returns>
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);
}
/// <summary>
/// Client-side validation surfaced inline by the form. Returns the first error, or <c>null</c>
/// when the model is valid. Every bound mirrors a <c>[Range]</c> on
/// <see cref="MqttDriverOptions"/> (or, for QoS, on <see cref="MqttPlainOptions"/>), so the
/// authoring surface accepts exactly what the driver accepts.
/// </summary>
/// <returns>An error message describing the validation failure, or <c>null</c> when valid.</returns>
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;
}
/// <summary>Blank ⇒ <c>null</c> so the key is omitted and the driver's own default applies.</summary>
/// <param name="value">The raw form value.</param>
/// <returns>The trimmed value, or <c>null</c> when blank.</returns>
private static string? Blank(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
/// <summary>Removes every key matching <paramref name="name"/> case-insensitively.</summary>
/// <param name="o">The object to mutate.</param>
/// <param name="name">The key name to remove.</param>
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);
}
}
/// <summary>Binds the blob through the shared options; <c>null</c> on blank/malformed input.</summary>
/// <param name="json">The raw <c>DriverConfig</c> JSON.</param>
/// <returns>The bound options, or <c>null</c>.</returns>
private static MqttDriverOptions? TryDeserialize(string? json)
{
if (string.IsNullOrWhiteSpace(json)) { return null; }
try { return JsonSerializer.Deserialize<MqttDriverOptions>(json, MqttJson.Options); }
catch (JsonException) { return null; }
}
}