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
+41 -9
View File
@@ -48,24 +48,50 @@ Driver-type string: **`Mqtt`** (`DriverTypeNames.Mqtt`).
## Configuration ## Configuration
Bound through `MqttJson.Options`**case-insensitive**, enums by **name**, unknown members skipped. 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 ```jsonc
// DriverConfig // DriverConfig — authored by MqttDriverForm. PascalCase keys, enums as names.
{ "Mode": "Plain", "Plain": { "TopicPrefix": "", "DefaultQos": 1 } }
// DeviceConfig
{ {
"Host": "10.100.0.35", "Host": "10.100.0.35",
"Port": 8883, "Port": 8883,
"UseTls": true, "UseTls": true,
"AllowUntrustedServerCertificate": false,
"CaCertificatePath": null, // pins the chain; null = OS trust store
"Username": "otopcua", "Username": "otopcua",
"Password": "", // supply via ${secret:} / env — never commit "Password": "", // never commit
"CaCertificatePath": null // pins the chain; null = OS trust store "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: Every key has a default, so nothing is strictly required by the binder. Notable ones:
`port` **8883**, `useTls` **true**, `protocolVersion` **V500**, `cleanSession` **true**, `port` **8883**, `useTls` **true**, `protocolVersion` **V500**, `cleanSession` **true**,
`keepAliveSeconds` **30**, `connectTimeoutSeconds` **15**, `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 <id> already connected, closing old connection` and both > other forever — the broker logs `Client <id> already connected, closing old connection` and both
> nodes reconnect every few seconds while still reporting `Healthy`. Unset (the default) lets MQTTnet > 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. > 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 165535, keep-alive /
connect-timeout / backoffs ≥ 1 s, max-backoff ≥ min-backoff, `maxPayloadBytes` ≥ 1, QoS 02) and
additionally **clamps** on serialize, so an operator who ignores the inline error still cannot persist
a `connectTimeoutSeconds: 0` driver-brick.
## Tag Configuration ## Tag Configuration
@@ -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: case DriverTypeNames.Galaxy:
<GalaxyDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" /> <GalaxyDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break; break;
case DriverTypeNames.Mqtt:
<MqttDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
default: default:
<div class="alert alert-warning">No typed device form for driver type <span class="mono">@_driverType</span>.</div> <div class="alert alert-warning">No typed device form for driver type <span class="mono">@_driverType</span>.</div>
break; break;
@@ -57,14 +57,30 @@
case DriverTypeNames.Galaxy: case DriverTypeNames.Galaxy:
<GalaxyDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" /> <GalaxyDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break; break;
case DriverTypeNames.Mqtt:
<MqttDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
default: default:
<div class="alert alert-warning">No typed config form for driver type <span class="mono">@_driverType</span>.</div> <div class="alert alert-warning">No typed config form for driver type <span class="mono">@_driverType</span>.</div>
break; break;
} }
<p class="form-text mt-3 mb-0"> @* Galaxy + MQTT hold ONE connection per driver instance, so they author it here and
The connection endpoint + Test-connect live on the driver's <strong>device</strong> — open the device modal to author host/port and verify connectivity. their device forms are informational. Every other driver splits the endpoint onto
</p> 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) @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; }
}
}
@@ -4,6 +4,7 @@ using System.Text.Json.Serialization;
using Shouldly; using Shouldly;
using Xunit; using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms; using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
@@ -23,15 +24,45 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
/// </summary> /// </summary>
public sealed class DriverPageJsonConverterTests public sealed class DriverPageJsonConverterTests
{ {
/// <summary>Every concrete <c>*DriverForm</c> in the AdminUI assembly that declares a /// <summary>
/// <c>_jsonOpts</c> config serializer.</summary> /// Driver forms whose config serializer does NOT live in a private static <c>_jsonOpts</c> 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 <em>where</em> the options live, never <em>whether</em> they are
/// checked.
/// <para><c>MqttDriverForm</c>: the MQTT driver deliberately keeps <b>one</b>
/// <see cref="JsonSerializerOptions"/> for all five of its config seams (runtime factory,
/// Test-connect probe, driver re-parse, address-picker browser, this form) in
/// <c>MqttJson.Options</c> — 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
/// <c>MqttDriverFormModel</c>, which serialises through that shared instance.</para>
/// </summary>
private static IReadOnlyDictionary<Type, JsonSerializerOptions> ExternalConfigSerializers { get; } =
new Dictionary<Type, JsonSerializerOptions>
{
[typeof(MqttDriverForm)] = MqttJson.Options,
};
/// <summary>Every concrete <c>*DriverForm</c> in the AdminUI assembly.</summary>
private static IReadOnlyList<Type> DriverFormTypes { get; } = private static IReadOnlyList<Type> DriverFormTypes { get; } =
typeof(ModbusDriverForm).Assembly.GetTypes() typeof(ModbusDriverForm).Assembly.GetTypes()
.Where(t => t.Name.EndsWith("DriverForm", StringComparison.Ordinal) && !t.IsAbstract) .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) .OrderBy(t => t.Name)
.ToList(); .ToList();
/// <summary>Resolves a form's config serializer — its own <c>_jsonOpts</c> field, else the
/// explicitly-registered external instance — or <c>null</c> when it has neither.</summary>
/// <param name="formType">A driver form component type.</param>
/// <returns>The options the form serialises config through, or <c>null</c>.</returns>
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);
}
/// <summary>xUnit theory source over the driver-form types discovered by reflection.</summary> /// <summary>xUnit theory source over the driver-form types discovered by reflection.</summary>
public static TheoryData<Type> DriverFormsWithJsonOpts() public static TheoryData<Type> DriverFormsWithJsonOpts()
{ {
@@ -48,24 +79,28 @@ public sealed class DriverPageJsonConverterTests
[MemberData(nameof(DriverFormsWithJsonOpts))] [MemberData(nameof(DriverFormsWithJsonOpts))]
public void Driver_form_json_options_register_string_enum_converter(Type formType) public void Driver_form_json_options_register_string_enum_converter(Type formType)
{ {
var field = formType.GetField("_jsonOpts", BindingFlags.NonPublic | BindingFlags.Static); var opts = ResolveConfigSerializer(formType);
var opts = (JsonSerializerOptions)field!.GetValue(null)!; 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<JsonStringEnumConverter>().ShouldNotBeEmpty( opts.Converters.OfType<JsonStringEnumConverter>().ShouldNotBeEmpty(
$"{formType.Name}._jsonOpts must register a JsonStringEnumConverter; otherwise AdminUI-authored " + $"{formType.Name}'s config serializer must register a JsonStringEnumConverter; otherwise " +
"enum config fields serialise as numbers and the string-typed driver factory throws on parse."); "AdminUI-authored enum config fields serialise as numbers and the string-typed driver factory " +
"throws on parse.");
} }
/// <summary>Enforces that EVERY concrete <c>*DriverForm</c> routes config serialization through a /// <summary>Enforces that EVERY concrete <c>*DriverForm</c> routes config serialization through a
/// <c>_jsonOpts</c> field — otherwise a new form that serialised config a different way would slip /// serializer the guard above can reach — otherwise a new form that serialised config a different way
/// past the converter guard above. Also a floor check so a rename can't silently shrink the set.</summary> /// would slip past it. Also a floor check so a rename can't silently shrink the set.</summary>
[Fact] [Fact]
public void Every_driver_form_uses_a_guarded_jsonOpts_serializer() public void Every_driver_form_uses_a_guarded_jsonOpts_serializer()
{ {
var allDriverForms = typeof(ModbusDriverForm).Assembly.GetTypes() DriverFormTypes.Count.ShouldBeGreaterThanOrEqualTo(9, "reflection should discover the full driver-form fleet");
.Where(t => t.Name.EndsWith("DriverForm", StringComparison.Ordinal) && !t.IsAbstract)
.ToList(); var unguarded = DriverFormTypes.Where(t => ResolveConfigSerializer(t) is null).Select(t => t.Name).ToList();
allDriverForms.Count.ShouldBeGreaterThanOrEqualTo(8, "reflection should discover the full driver-form fleet"); unguarded.ShouldBeEmpty(
DriverFormTypes.Count.ShouldBe(allDriverForms.Count, "every *DriverForm must expose its config serializer (a _jsonOpts field, or an ExternalConfigSerializers " +
"every *DriverForm must declare a _jsonOpts config serializer so the string-enum converter guard covers it"); "entry) so the string-enum converter guard covers it");
} }
} }
@@ -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;
/// <summary>
/// Guards for the MQTT <b>device</b> 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 <i>legacy</i> connection keys left on a
/// DeviceConfig by a pre-form (hand-authored / SQL-seeded) deployment, because
/// <c>DriverDeviceConfigMerger</c> merges a sole device's keys UP over the driver's and they would
/// otherwise silently win over what the driver form shows.
/// </summary>
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<string>().ShouldBe("10.100.0.35");
o["Port"]!.GetValue<int>().ShouldBe(8883);
o["anything"]!["nested"]!.GetValue<bool>().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();
}
@@ -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;
/// <summary>
/// Round-trip, enum-serialization and validation guards for the MQTT <b>driver</b> config form model.
/// <para>
/// The load-bearing assertion is <see cref="Serialized_blob_binds_onto_MqttDriverOptions_through_the_shared_MqttJson_options"/>:
/// it deserializes this form's output through <see cref="MqttJson.Options"/> — the exact instance
/// <c>MqttDriverFactoryExtensions.CreateInstance</c> and <c>MqttDriverProbe</c> 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.
/// </para>
/// </summary>
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<MqttDriverOptions>(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<JsonException>(
() => JsonSerializer.Deserialize<MqttDriverOptions>(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<string>().ShouldBe("G1");
o["sparkplug"]!["ActAsPrimaryHost"]!.GetValue<bool>().ShouldBeTrue();
o["aFutureKey"]!.GetValue<int>().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<MqttDriverOptions>(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<MqttDriverOptions>(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<DriverDeviceConfigMerger.DeviceRow>(),
[new DriverDeviceConfigMerger.DeviceRow("Dev1", "{}")],
[new DriverDeviceConfigMerger.DeviceRow("Dev1", "{}"), new DriverDeviceConfigMerger.DeviceRow("Dev2", "{}")],
})
{
var merged = DriverDeviceConfigMerger.Merge(model.ToJson(), devices, rawTags);
var options = JsonSerializer.Deserialize<MqttDriverOptions>(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<MqttDriverOptions>(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();
}
}