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:
+41
-9
@@ -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 1–65535, keep-alive /
|
||||||
|
connect-timeout / backoffs ≥ 1 s, max-backoff ≥ min-backoff, `maxPayloadBytes` ≥ 1, QoS 0–2) and
|
||||||
|
additionally **clamps** on serialize, so an operator who ignores the inline error still cannot persist
|
||||||
|
a `connectTimeoutSeconds: 0` driver-brick.
|
||||||
|
|
||||||
## Tag Configuration
|
## Tag Configuration
|
||||||
|
|
||||||
|
|||||||
+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:
|
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;
|
||||||
|
|||||||
+19
-3
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
+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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user