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:
+61
@@ -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();
|
||||
}
|
||||
+73
@@ -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;
|
||||
|
||||
+19
-3
@@ -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)
|
||||
{
|
||||
|
||||
+274
@@ -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);
|
||||
}
|
||||
}
|
||||
+296
@@ -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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user