Merge branch 'feat/mqtt-sparkplug-driver'
v2-ci / build (push) Successful in 5m36s
v2-ci / unit-tests (push) Failing after 22m40s

# Conflicts:
#	src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs
#	src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor
#	src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs
#	src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs
#	src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs
#	src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj
#	src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs
This commit is contained in:
Joseph Doherty
2026-07-27 13:31:39 -04:00
109 changed files with 27428 additions and 62 deletions
@@ -0,0 +1,61 @@
@* MQTT device form — informational only. An MQTT driver instance holds exactly one broker session,
configured on the DRIVER (MqttDriverForm), so there is no per-device connection endpoint to author;
devices are pure organisational grouping in the RawPath. The DeviceConfig is round-tripped verbatim,
and Test-connect uses the driver's broker settings via the merged config. Same shape as
GalaxyDeviceForm.
The warning below fires only for a pre-form deployment that authored the connection on the device:
DriverDeviceConfigMerger merges a SOLE device's keys up over the driver's, so those keys keep
winning over what the driver form shows. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
<section class="panel notice rise" style="animation-delay:.04s">
<div class="panel-head">Connection</div>
<div style="padding:1rem" class="text-muted">
MQTT connects to a single broker configured on the <strong>driver</strong> — there is no per-device
endpoint to author here. Edit host / port / TLS / credentials on the driver's config. Devices under an
MQTT driver exist purely to group tags in the RawPath.
</div>
</section>
@if (_model.LegacyConnectionKeys.Count > 0)
{
<section class="panel rise mt-3" style="animation-delay:.06s;border-color:var(--alert)">
<div class="panel-head">Legacy connection keys on this device</div>
<div style="padding:1rem">
<p class="mb-2 small">
This device's config still carries broker connection keys from before the driver form existed:
<span class="mono">@string.Join(", ", _model.LegacyConnectionKeys)</span>.
</p>
<p class="mb-0 small">
While this driver has <strong>exactly one</strong> device these keys are merged up and
<strong>override</strong> the driver form's values — so the driver form may not show what is
actually in effect. They are preserved (nothing is dropped), but the durable fix is to author the
connection on the driver and clear these keys: a second device on this driver stops the merge-up
entirely and the connection would vanish.
</p>
</div>
</section>
}
@code {
/// <summary>The device's DeviceConfig JSON (round-tripped verbatim — MQTT has no per-device endpoint).</summary>
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
/// <summary>Fired when the (preserved) DeviceConfig changes — no-op UI, kept for the modal contract.</summary>
[Parameter] public EventCallback<string> DeviceConfigJsonChanged { get; set; }
private MqttDeviceModel _model = MqttDeviceModel.FromJson(null);
private string? _lastParsed;
protected override void OnParametersSet()
{
if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal))
{
_model = MqttDeviceModel.FromJson(DeviceConfigJson);
_lastParsed = DeviceConfigJson;
}
}
/// <summary>Serialises the (preserved) DeviceConfig JSON.</summary>
public string GetConfigJson() => _model.ToJson();
}
@@ -0,0 +1,73 @@
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
/// <summary>
/// Device model for the MQTT / Sparkplug B driver. An MQTT driver instance holds exactly <b>one</b>
/// broker session, configured on the <b>driver</b> (<c>MqttDriverForm</c>) — so, like Galaxy, there
/// is no per-device connection endpoint to author and this model round-trips the <c>DeviceConfig</c>
/// JSON verbatim. Devices under an MQTT driver are pure organisational grouping in the RawPath.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why the connection is not authored here.</b> <c>DriverDeviceConfigMerger</c> merges a device's
/// keys up to the top level <i>only when the driver has exactly one device</i>. A broker connection
/// authored on the device would therefore disappear from the merged blob the moment an operator adds
/// a second device — a silent, deploy-time-invisible outage. Driver-level authoring is correct for
/// 0, 1 or N devices.
/// </para>
/// <para>
/// <b><see cref="LegacyConnectionKeys"/> exists because that merge-up still happens.</b> A
/// pre-form deployment (hand-authored or SQL-seeded, as the P1 live gate had to do) may carry
/// <c>Host</c>/<c>Port</c>/… on a sole device's <c>DeviceConfig</c>, and those keys keep winning over
/// the driver form's values. They are preserved (never silently dropped) and reported so the device
/// form can tell the operator why the driver form's host is not the one in effect.
/// </para>
/// </remarks>
public sealed class MqttDeviceModel
{
/// <summary>
/// <c>MqttDriverOptions</c> connection-surface key names that a legacy <c>DeviceConfig</c> may
/// carry and that would merge up over the driver form's values.
/// </summary>
private static readonly string[] ConnectionKeyNames =
[
"Host", "Port", "ClientId", "UseTls", "AllowUntrustedServerCertificate",
"CaCertificatePath", "Username", "Password", "ProtocolVersion", "CleanSession",
"KeepAliveSeconds", "ConnectTimeoutSeconds", "ReconnectMinBackoffSeconds",
"ReconnectMaxBackoffSeconds",
];
private System.Text.Json.Nodes.JsonObject _bag = new();
/// <summary>
/// The connection keys this <c>DeviceConfig</c> actually carries, in the casing they were
/// authored with — empty for the normal case. Non-empty means a sole-device merge-up will
/// override the driver form for those keys.
/// </summary>
public IReadOnlyList<string> LegacyConnectionKeys { get; private set; } = [];
/// <summary>Loads a model from a <c>DeviceConfig</c> JSON string, retaining every original key.</summary>
/// <param name="json">The raw <c>DeviceConfig</c> JSON string, or <c>null</c> for a new/empty device.</param>
/// <returns>The populated <see cref="MqttDeviceModel"/>.</returns>
public static MqttDeviceModel FromJson(string? json)
{
var bag = TagConfigJson.ParseOrNew(json);
return new MqttDeviceModel
{
_bag = bag,
LegacyConnectionKeys = bag
.Select(p => p.Key)
.Where(k => ConnectionKeyNames.Contains(k, StringComparer.OrdinalIgnoreCase))
.ToList(),
};
}
/// <summary>Serialises the (preserved) <c>DeviceConfig</c> back to a JSON string.</summary>
/// <returns>The serialised <c>DeviceConfig</c> JSON string.</returns>
public string ToJson() => TagConfigJson.Serialize(_bag);
/// <summary>Validation hook; MQTT device rows carry no endpoint, so always valid.</summary>
/// <returns><c>null</c> — no per-device endpoint to validate.</returns>
public string? Validate() => null;
}
@@ -65,6 +65,9 @@
case DriverTypeNames.Galaxy:
<GalaxyDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
case DriverTypeNames.Mqtt:
<MqttDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
default:
<div class="alert alert-warning">No typed device form for driver type <span class="mono">@_driverType</span>.</div>
break;
@@ -34,6 +34,12 @@
/// <summary>Fired when the user clicks a leaf (or any node — caller decides what to do with it).</summary>
[Parameter] public EventCallback<BrowseNode> OnNodeSelected { get; set; }
/// <summary>The same click as <see cref="OnNodeSelected"/>, plus the node's captured ancestor-folder
/// path (root→parent) — which is how a caller learns a node's DEPTH, and therefore what kind of thing
/// it is in a driver whose tree levels mean different things (MQTT/Sparkplug: group / edge node /
/// device). Both callbacks fire; wiring only the first is the existing behaviour.</summary>
[Parameter] public EventCallback<ZB.MOM.WW.OtOpcUa.AdminUI.Browsing.BrowseLeafSelection> OnNodeSelectedWithPath { get; set; }
/// <summary>When true, leaves render a selection checkbox and clicking a leaf toggles it (WP6 "Browse
/// device…" multi-select) instead of single-selecting. Folders still expand/navigate as usual. Default
/// false preserves the single-select address-picker behavior.</summary>
@@ -93,10 +99,12 @@
finally { item.Loading = false; StateHasChanged(); }
}
private async Task SelectAsync(TreeItem item)
private async Task SelectAsync(TreeItem item, IReadOnlyList<string> ancestorPath)
{
_selectedNodeIdLocal = item.Node.NodeId;
await OnNodeSelected.InvokeAsync(item.Node);
await OnNodeSelectedWithPath.InvokeAsync(
new ZB.MOM.WW.OtOpcUa.AdminUI.Browsing.BrowseLeafSelection(item.Node, ancestorPath));
}
private async Task ToggleLeafAsync(TreeItem item, IReadOnlyList<string> ancestorPath)
@@ -133,17 +141,27 @@
{
<span style="width:18px"></span>
}
@* BUTTONS, NOT <a href="#">. This is a Blazor Web App: blazor.web.js installs a
document-level click interceptor for enhanced navigation, and it resolves a bare "#"
against <base href="/"> rather than the current URL — so clicking a node label
navigated the whole AdminUI to "/", tore down the circuit, and destroyed whatever modal
the tree was hosted in (losing the browse session AND the tag selection with it).
@onclick:preventDefault does not help: it suppresses the BROWSER's default action, not
Blazor's own interceptor. A <button> is never a navigation candidate, so the handler is
the only thing that runs. Found by the MQTT/Sparkplug P2 live gate — the Request-rebirth
scope is chosen by clicking a FOLDER label, which is the first feature that ever
required clicking a label rather than the ▶ toggle (a button, which always worked). *@
@if (isMultiLeaf)
{
<input type="checkbox" class="form-check-input" checked="@IsLeafSelected(item.Node.NodeId)"
@onchange="@(() => ToggleLeafAsync(item, ancestorPath))" />
<a href="#" @onclick="@(() => ToggleLeafAsync(item, ancestorPath))" @onclick:preventDefault
class="text-decoration-none mono small">@item.Node.DisplayName</a>
<button type="button" @onclick="@(() => ToggleLeafAsync(item, ancestorPath))"
class="btn btn-link btn-sm p-0 text-decoration-none mono small text-start">@item.Node.DisplayName</button>
}
else
{
<a href="#" @onclick="@(() => SelectAsync(item))" @onclick:preventDefault
class="text-decoration-none mono small">@item.Node.DisplayName</a>
<button type="button" @onclick="@(() => SelectAsync(item, ancestorPath))"
class="btn btn-link btn-sm p-0 text-decoration-none mono small text-start">@item.Node.DisplayName</button>
}
@if (item.Node.Kind == BrowseNodeKind.Leaf)
{
@@ -60,14 +60,30 @@
case DriverTypeNames.Sql:
<SqlDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.Mqtt:
<MqttDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
default:
<div class="alert alert-warning">No typed config form for driver type <span class="mono">@_driverType</span>.</div>
break;
}
<p class="form-text mt-3 mb-0">
The connection endpoint + Test-connect live on the driver's <strong>device</strong> — open the device modal to author host/port and verify connectivity.
</p>
@* Galaxy + MQTT hold ONE connection per driver instance, so they author it here and
their device forms are informational. Every other driver splits the endpoint onto
the device (the v3 endpoint→DeviceConfig split). *@
@if (_driverType is DriverTypeNames.Galaxy or DriverTypeNames.Mqtt)
{
<p class="form-text mt-3 mb-0">
This driver holds a single connection, authored above. Test-connect lives on its
<strong>device</strong> — open the device modal to verify connectivity.
</p>
}
else
{
<p class="form-text mt-3 mb-0">
The connection endpoint + Test-connect live on the driver's <strong>device</strong> — open the device modal to author host/port and verify connectivity.
</p>
}
@if (_saveError is not null)
{
@@ -0,0 +1,331 @@
@* 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 authored by the Mode == SparkplugB branch below. It is MERGED over
whatever the inbound blob had rather than replacing it, and in Plain mode it is not touched at all —
see MqttDriverFormModel.ToJson. Until the P2 live gate this branch was a "not implemented yet"
placeholder, which left the group id — the driver's entire subscription filter — unauthorable from
the UI after Sparkplug ingest had shipped. *@
@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 = birth-described metrics under one group.</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
{
@* Sparkplug B. The group id is the ONE mandatory field: it is the driver's entire
subscription filter (spBv1.0/{GroupId}/#), so a blank one leaves the driver connected,
Healthy, and ingesting nothing. Validate() surfaces that inline; like every other knob
on this form it is ADVISORY — DriverConfigModal saves regardless — which is why the
model clamps on serialize instead of relying on the operator reading the notice. *@
<div class="col-md-3">
<label class="form-label" for="mqtt-spb-group">Group ID</label>
<InputText id="mqtt-spb-group" @bind-Value="_form.SparkplugGroupId" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="Plant1" />
<div class="form-text">
Subscribes <code class="mono">spBv1.0/@(string.IsNullOrWhiteSpace(_form.SparkplugGroupId) ? "{GroupId}" : _form.SparkplugGroupId)/#</code>.
No <code>/</code>, <code>+</code>, or <code>#</code>.
</div>
</div>
<div class="col-md-3">
<label class="form-label" for="mqtt-spb-window">Birth observation window (seconds)</label>
<InputNumber id="mqtt-spb-window" @bind-Value="_form.SparkplugBirthObservationWindowSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 15 s. How long browse/discovery collects births before calling the metric set stable.</div>
</div>
<div class="col-md-3">
<label class="form-label" for="mqtt-spb-host">Host ID (optional)</label>
<InputText id="mqtt-spb-host" @bind-Value="_form.SparkplugHostId" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="scada-primary" />
<div class="form-text">Sparkplug Host Application identity. Receive-only in this version.</div>
</div>
<div class="col-md-3 d-flex flex-column justify-content-center">
<div class="form-check form-switch">
<InputCheckbox id="mqtt-spb-rebirth" @bind-Value="_form.SparkplugRequestRebirthOnGap" @bind-Value:after="EmitAsync" class="form-check-input" />
<label class="form-check-label" for="mqtt-spb-rebirth">Request rebirth on sequence gap</label>
</div>
<div class="form-text mb-2">A detected seq gap asks the edge node to re-announce (NCMD), rather than running on stale metric state.</div>
<div class="form-check form-switch">
<InputCheckbox id="mqtt-spb-primary" @bind-Value="_form.SparkplugActAsPrimaryHost" @bind-Value:after="EmitAsync" class="form-check-input" />
<label class="form-check-label" for="mqtt-spb-primary">
Act as Primary Host
<span class="badge text-bg-warning">not implemented</span>
</label>
</div>
</div>
@if (_form.SparkplugActAsPrimaryHost)
{
@* Shown rather than the flag being hidden: an operator who needs a primary host must
learn it is absent HERE, not from a plant that never sees a STATE message. The
driver logs the same warning at startup so the two cannot drift. *@
<div class="col-12">
<div class="alert alert-warning py-2 px-3 mb-0 small" role="alert">
<strong>Primary-host STATE publishing is not implemented.</strong> This driver is
receive-only: it will not publish <code class="mono">spBv1.0/STATE/@(string.IsNullOrWhiteSpace(_form.SparkplugHostId) ? "{HostId}" : _form.SparkplugHostId)</code>,
so edge nodes will not treat it as their primary host. The driver logs a warning at
startup rather than being silently inert. Leave this off unless you are staging
configuration ahead of that feature.
</div>
</div>
}
<div class="col-12">
<div class="alert alert-info py-2 px-3 mb-0 small" role="alert">
Sparkplug tags bind by the <code class="mono">group / edge node / device / metric</code>
tuple, not by topic — author them with <strong>Browse device…</strong> on a device under
this driver, which builds its tree from observed birth certificates.
<strong>MQTT tags are read-only</strong> (write-through is not implemented).
</div>
</div>
}
</div>
</div>
</section>
@if (_validationError is not null)
{
<div class="panel notice mt-3" style="border-color:var(--alert)">@_validationError</div>
}
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
@code {
/// <summary>The driver-level DriverConfig JSON (broker connection + ingest mode).</summary>
[Parameter] public string DriverConfigJson { get; set; } = "{}";
/// <summary>Fired whenever a field changes — enables @bind-DriverConfigJson.</summary>
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
/// <summary>The per-instance resilience-pipeline overrides JSON, or null.</summary>
[Parameter] public string? ResilienceConfig { get; set; }
/// <summary>Fired when the resilience overrides change.</summary>
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
private MqttDriverFormModel _form = MqttDriverFormModel.FromJson(null);
private string? _lastParsed;
private string? _validationError;
protected override void OnParametersSet()
{
// Re-parse only when the inbound value actually changed, so an unrelated parent re-render
// cannot clobber an in-progress edit.
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
{
_form = MqttDriverFormModel.FromJson(DriverConfigJson);
_validationError = _form.Validate();
_lastParsed = DriverConfigJson;
}
}
/// <summary>Serialises the current config to DriverConfig JSON (PascalCase keys, enums as names).</summary>
public string GetConfigJson() => _form.ToJson();
// TryParse so a bad/empty change value can never throw into the Blazor circuit — it falls back to
// the driver's own default QoS rather than poisoning the config.
private Task OnDefaultQosChangedAsync(object? value)
{
_form.DefaultQos = int.TryParse(value?.ToString(), out var qos) ? qos : 1;
return EmitAsync();
}
private async Task EmitAsync()
{
_validationError = _form.Validate();
var json = GetConfigJson();
_lastParsed = json;
await DriverConfigJsonChanged.InvokeAsync(json);
}
private async Task OnResilienceChanged(string? r)
{
ResilienceConfig = r;
await ResilienceConfigChanged.InvokeAsync(r);
}
}
@@ -0,0 +1,430 @@
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 Sparkplug sub-object. Authored here
/// in <see cref="MqttMode.SparkplugB"/> mode and otherwise preserved verbatim from the inbound
/// blob — see <see cref="ToJson"/> for why the mode decides which of the two happens.
/// </summary>
private const string SparkplugKey = nameof(MqttDriverOptions.Sparkplug);
/// <summary>
/// Characters that cannot appear in a Sparkplug group id. It is a literal MQTT topic segment
/// inside the driver's one subscription filter <c>spBv1.0/{GroupId}/#</c>, so a <c>/</c> would
/// silently widen the filter and a <c>+</c>/<c>#</c> is not even legal mid-segment. Mirrors
/// <c>MqttTagConfigModel</c>'s identical rule on the tag side, so the driver and the tags bound
/// to it cannot disagree about what a group id may be.
/// </summary>
private static readonly char[] SparkplugGroupIdIllegalChars = ['/', '+', '#'];
// --- 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;
// --- Sparkplug B ----------------------------------------------------------------------------
/// <summary>
/// Sparkplug mode: the group id. <b>Required</b> — it is the driver's entire subscription scope
/// (<c>spBv1.0/{GroupId}/#</c>), so a blank one subscribes to nothing and the driver ingests
/// nothing while still reporting a healthy broker connection.
/// </summary>
public string SparkplugGroupId { get; set; } = "";
/// <summary>
/// Sparkplug mode: the Host Application identity. Optional, and <b>receive-only</b> in this
/// version — see <see cref="SparkplugActAsPrimaryHost"/>.
/// </summary>
public string SparkplugHostId { get; set; } = "";
/// <summary>
/// Sparkplug mode: claim the Primary Host Application role. <b>Not implemented</b> — STATE
/// publishing is out of scope, and the driver logs a warning rather than being silently inert
/// when this is set. Surfaced (rather than hidden) so an operator who needs it learns that from
/// the form instead of from a quiet plant.
/// </summary>
public bool SparkplugActAsPrimaryHost { get; set; }
/// <summary>Sparkplug mode: answer a detected sequence-number gap with a rebirth request (NCMD).</summary>
public bool SparkplugRequestRebirthOnGap { get; set; } = true;
/// <summary>Sparkplug mode: how long browse/discovery collects births before calling the set stable.</summary>
public int SparkplugBirthObservationWindowSeconds { get; set; } = 15;
/// <summary>
/// The inbound blob's keys, retained so any key a newer driver adds survives 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,
SparkplugGroupId = o.Sparkplug?.GroupId ?? "",
SparkplugHostId = o.Sparkplug?.HostId ?? "",
SparkplugActAsPrimaryHost = o.Sparkplug?.ActAsPrimaryHost ?? false,
// Defaulted from the record, not from a literal, so the form shows the driver's own default
// for a blob that has no Sparkplug sub-object at all.
SparkplugRequestRebirthOnGap =
o.Sparkplug?.RequestRebirthOnGap ?? new MqttSparkplugOptions().RequestRebirthOnGap,
SparkplugBirthObservationWindowSeconds =
o.Sparkplug?.BirthObservationWindowSeconds
?? new MqttSparkplugOptions().BirthObservationWindowSeconds,
_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.RawTags"/> stays empty (the deploy artifact owns it) and
/// <see cref="MqttDriverOptions.Sparkplug"/> is emitted only in
/// <see cref="MqttMode.SparkplugB"/> mode; both are finished 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),
},
// Null in Plain mode so ToJson leaves whatever the inbound blob had; see its remarks.
Sparkplug = Mode == MqttMode.SparkplugB
? new MqttSparkplugOptions
{
GroupId = SparkplugGroupId.Trim(),
HostId = SparkplugHostId.Trim(),
ActAsPrimaryHost = SparkplugActAsPrimaryHost,
RequestRebirthOnGap = SparkplugRequestRebirthOnGap,
BirthObservationWindowSeconds = Math.Max(1, SparkplugBirthObservationWindowSeconds),
}
: null,
};
/// <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>RawTags</c> is removed (the deploy
/// artifact owns it).
/// </summary>
/// <remarks>
/// <b><c>Sparkplug</c> is merged, never replaced, and only in Sparkplug mode.</b> In
/// <see cref="MqttMode.Plain"/> the sub-object is left exactly as the inbound blob had it, so an
/// operator who flips a Sparkplug driver to Plain to look at it and flips back does not lose the
/// group id. In <see cref="MqttMode.SparkplugB"/> the five fields this form owns are written
/// <i>over</i> the existing sub-object rather than replacing it, so a key a newer driver adds
/// inside <c>Sparkplug</c> survives an older AdminUI — the same preserve-what-you-do-not-author
/// discipline the top-level bag applies.
/// </remarks>
/// <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-empty placeholder overwrite what it does not own.
typed.Remove(RawTagsKey);
// Pulled out of the flat copy loop below: unlike every other key, this one merges.
var authoredSparkplug = TakeIgnoringCase(typed, SparkplugKey) as JsonObject;
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();
}
if (authoredSparkplug is not null)
{
// Merge over whatever was already there, under the key name the bag already uses so a
// camelCase hand-edited blob does not end up with both "sparkplug" and "Sparkplug".
var existingKey = FindIgnoringCase(_bag, SparkplugKey) ?? SparkplugKey;
if (_bag[existingKey] is not JsonObject target)
{
target = new JsonObject();
_bag[existingKey] = target;
}
foreach (var (key, value) in authoredSparkplug.ToList())
{
RemoveIgnoringCase(target, key);
target[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."; }
if (Mode == MqttMode.SparkplugB)
{
var groupId = SparkplugGroupId.Trim();
if (groupId.Length == 0)
{
return "A Sparkplug group ID is required — it is the driver's whole subscription scope "
+ "(spBv1.0/{GroupId}/#), so a blank one ingests nothing.";
}
if (groupId.IndexOfAny(SparkplugGroupIdIllegalChars) >= 0)
{
return $"Sparkplug group ID '{groupId}' contains a character ('/', '+', or '#') that "
+ "cannot appear in an MQTT topic segment.";
}
if (SparkplugBirthObservationWindowSeconds < 1)
{
return "Birth observation window must be at least 1 second.";
}
}
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>The actual key in <paramref name="o"/> matching <paramref name="name"/> case-insensitively.</summary>
/// <param name="o">The object to search.</param>
/// <param name="name">The key name to look for.</param>
/// <returns>The matching key as it is spelled in <paramref name="o"/>, or <c>null</c>.</returns>
private static string? FindIgnoringCase(JsonObject o, string name)
=> o.Select(p => p.Key)
.FirstOrDefault(k => string.Equals(k, name, StringComparison.OrdinalIgnoreCase));
/// <summary>Removes and returns the value of a case-insensitively matched key.</summary>
/// <param name="o">The object to mutate.</param>
/// <param name="name">The key name to take.</param>
/// <returns>The detached value, or <c>null</c> when the key was absent (or its value was null).</returns>
private static JsonNode? TakeIgnoringCase(JsonObject o, string name)
{
if (FindIgnoringCase(o, name) is not { } key) { return null; }
var value = o[key];
o.Remove(key);
// Detached before return: a node still parented to `typed` cannot be re-parented into the bag.
return value?.DeepClone();
}
/// <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; }
}
}
@@ -11,6 +11,8 @@
SupportsOnlineDiscovery (AbCip/TwinCAT/FOCAS); otherwise browse is unavailable and the modal grays out
with a tooltip. *@
@implements IAsyncDisposable
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@@ -18,6 +20,8 @@
@inject IBrowserSessionService BrowserService
@inject IRawTreeService Svc
@inject RawTagCsvExportReader ExportReader
@inject AuthenticationStateProvider AuthState
@inject IAuthorizationService AuthorizationService
@if (Visible)
{
@@ -66,7 +70,22 @@
else
{
<div class="d-flex align-items-center justify-content-between mb-2">
<span class="chip chip-ok">Browser open</span>
<div class="d-flex align-items-center gap-2">
<span class="chip chip-ok">Browser open</span>
@* An observation window ACCUMULATES — the session keeps recording every
message after the tree was first rendered — but the tree itself loads
exactly once, when it is created. Without this the operator sees the
t=0 snapshot forever: on MQTT the first render is usually empty (a
topic that has not published yet is invisible) and on Sparkplug it is
almost always empty, because births are never retained. Re-keying the
tree re-runs its root load against the SAME session, so nothing
reconnects and nothing already observed is lost. *@
<button type="button" class="btn btn-outline-secondary btn-sm"
title="Re-read what this session has observed since it opened"
@onclick="RefreshTree">
Refresh
</button>
</div>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="raw-browse-mirror"
checked="@_createGroups" @onchange="@(e => _createGroups = e.Value is true)" />
@@ -76,8 +95,90 @@
</div>
</div>
<DriverBrowseTree SessionToken="_token" MultiSelect="true"
SelectedLeafIds="_selectedIds" OnLeafToggled="OnLeafToggledAsync" />
<DriverBrowseTree @key="_treeGeneration" SessionToken="_token" MultiSelect="true"
SelectedLeafIds="_selectedIds" OnLeafToggled="OnLeafToggledAsync"
OnNodeSelectedWithPath="OnScopeNodeSelected" />
@if (_canRebirth && _canOperate)
{
@* Sparkplug only: births are never retained, so a quiet-but-healthy plant
shows an EMPTY tree until one lands. This is the only way to fill it on
demand — and the only browse action that publishes to the live broker,
hence the explicit confirm step. *@
<div class="border rounded p-2 mt-2 bg-body-tertiary">
<div class="d-flex align-items-center justify-content-between gap-2">
<div class="small">
<strong>Request rebirth</strong>
<span class="text-muted">
&mdash; asks edge nodes to republish their metric set, so this tree fills
without waiting for a natural birth.
</span>
</div>
@if (!_rebirthConfirming)
{
<button type="button" class="btn btn-outline-warning btn-sm text-nowrap"
disabled="@(_rebirthTarget is null || _rebirthBusy)"
@onclick="BeginRebirth">
Request rebirth&hellip;
</button>
}
</div>
@if (_rebirthTarget is null)
{
<div class="small text-muted mt-1">
Click a group, edge node, device or metric in the tree above to choose the scope.
</div>
}
else
{
<div class="small mt-1">
Scope: <strong>@_rebirthTarget.Kind</strong>
<code class="mono ms-1">@_rebirthTarget.Scope</code>
</div>
<div class="small text-muted">@_rebirthTarget.Detail</div>
}
@if (_rebirthConfirming && _rebirthTarget is not null)
{
<div class="alert alert-warning py-2 mt-2 mb-0">
<div class="small">
This <strong>publishes to the live broker</strong>: a Sparkplug
rebirth-request command addressed to @_rebirthTarget.Kind
(<code class="mono">@_rebirthTarget.Scope</code>).
@if (_rebirthTarget.IsGroup)
{
<text>
Every edge node observed in this group is addressed; a group with
more than @MqttGroupRebirthNodeCap observed edge nodes is refused
outright, and nothing is published.
</text>
}
</div>
<div class="d-flex gap-2 mt-2">
<button type="button" class="btn btn-warning btn-sm"
disabled="@_rebirthBusy" @onclick="ConfirmRebirthAsync">
@if (_rebirthBusy) { <span class="spinner-border spinner-border-sm me-1"></span> }
Publish rebirth request
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
disabled="@_rebirthBusy" @onclick="CancelRebirth">
Cancel
</button>
</div>
</div>
}
@if (_rebirthOutcome is not null)
{
<div class="small text-success mt-1">@_rebirthOutcome</div>
}
@if (_rebirthError is not null)
{
<div class="alert alert-danger py-2 mt-2 mb-0 small">@_rebirthError</div>
}
</div>
}
<div class="mt-2 small">
<strong>@_selected.Count</strong> tag@(_selected.Count == 1 ? "" : "s") selected.
@@ -116,6 +217,11 @@
/// type (the OPC UA Client tree). Driver-typed browsers (AbCip/TwinCAT/FOCAS) report the real type.</summary>
private const string DefaultDataType = "Double";
/// <summary>Mirrors <c>MqttBrowseSession.MaxGroupRebirthNodes</c> for the confirm-step wording. The
/// session owns the rule and refuses whole past it; this is the operator-facing number, and a drift
/// only ever makes the warning less precise, never the refusal wrong.</summary>
private const int MqttGroupRebirthNodeCap = 32;
/// <summary>Whether the modal is shown. Two-way (@bind-Visible) so the modal can self-close.</summary>
[Parameter] public bool Visible { get; set; }
@@ -154,9 +260,32 @@
private bool _busy;
private List<string> _commitErrors = new();
/// <summary>
/// Bumped to force a fresh <c>DriverBrowseTree</c> against the same session — see the Refresh
/// button's remarks. Seeded from the session so re-opening a modal always starts a new generation.
/// </summary>
private int _treeGeneration;
// Request-rebirth affordance (MQTT/Sparkplug only). _canOperate is defence in depth — the real gate
// is server-side in BrowserSessionService.RequestRebirthAsync, which fails closed.
private bool _canOperate;
private bool _canRebirth;
private RebirthTarget? _rebirthTarget;
private bool _rebirthConfirming;
private bool _rebirthBusy;
private string? _rebirthOutcome;
private string? _rebirthError;
private readonly Dictionary<string, SelectedLeaf> _selected = new(StringComparer.Ordinal);
private readonly HashSet<string> _selectedIds = new(StringComparer.Ordinal);
protected override async Task OnInitializedAsync()
{
var auth = await AuthState.GetAuthenticationStateAsync();
var result = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator);
_canOperate = result.Succeeded;
}
protected override async Task OnParametersSetAsync()
{
if (!Visible)
@@ -185,6 +314,8 @@
_groupPrefix = null;
_canBrowse = false;
_disabledReason = null;
ResetRebirthState();
_canRebirth = false;
_resolving = true;
StateHasChanged();
@@ -238,7 +369,13 @@
try
{
var result = await BrowserService.OpenAsync(_driverType, _mergedConfig, default);
if (result.Ok) { _token = result.Token; }
if (result.Ok)
{
_token = result.Token;
// Capability, not authorization: a plain-MQTT window (and every non-MQTT browser) has no
// re-announce action at all, so the affordance must not be drawn for it.
_canRebirth = BrowserService.CanRequestRebirth(_token);
}
else { _openError = result.Message; }
}
finally
@@ -263,6 +400,7 @@
// the display name + the default data type.
string name = sel.Leaf.DisplayName;
string? driverDataType = null;
IReadOnlyDictionary<string, string>? addressFields = null;
try
{
var attrs = await BrowserService.AttributesAsync(_token, nodeId, default);
@@ -270,15 +408,164 @@
{
name = string.IsNullOrWhiteSpace(attrs[0].Name) ? sel.Leaf.DisplayName : attrs[0].Name;
driverDataType = attrs[0].DriverDataType;
// The STRUCTURED address, for a driver whose binding is a tuple (MQTT/Sparkplug). Never
// reconstructed from the node id: a metric name may contain '/', so the id cannot be
// split back into (group, edgeNode, device?, metric) — see RawBrowseCommitMapper.
addressFields = attrs[0].AddressFields;
}
}
catch
{
// Attribute lookup is best-effort — a leaf is still selectable with display-name + default type.
// For a tuple-addressed driver that also means "no address", which CommitAsync refuses in words.
}
_selectedIds.Add(nodeId);
_selected[nodeId] = new SelectedLeaf(nodeId, name, driverDataType, sel.FolderPath);
_selected[nodeId] = new SelectedLeaf(nodeId, name, driverDataType, sel.FolderPath, addressFields);
// A metric is also a legal rebirth scope (it resolves up to its owning edge node), so a toggle
// moves the scope the same way a folder click does.
SetRebirthTarget(sel.Leaf, sel.FolderPath);
}
/// <summary>Tracks the tree click that defines the Request-rebirth scope (folders and metrics alike).</summary>
private void OnScopeNodeSelected(BrowseLeafSelection sel) => SetRebirthTarget(sel.Leaf, sel.FolderPath);
/// <summary>
/// Re-derives the rebirth scope from the clicked node, and — because the scope changed — drops any
/// pending confirmation. A confirm step that survived a selection change would publish to a target
/// the operator is no longer looking at.
/// </summary>
private void SetRebirthTarget(BrowseNode node, IReadOnlyList<string> ancestorPath)
{
if (!_canRebirth) { return; }
var target = DescribeRebirthTarget(node, ancestorPath);
if (_rebirthTarget?.Scope == target.Scope) { return; }
_rebirthTarget = target;
_rebirthConfirming = false;
_rebirthOutcome = null;
_rebirthError = null;
}
/// <summary>
/// Names what a clicked node means as a rebirth scope. The DEPTH comes from the captured ancestor
/// path, not from splitting the node id — the id is the session's own key and the AdminUI must not
/// reverse-engineer its shape.
/// </summary>
/// <param name="node">The clicked browse node; its NodeId is passed to the session verbatim.</param>
/// <param name="ancestorPath">The node's ancestor display names, root→parent.</param>
/// <returns>The described target.</returns>
private static RebirthTarget DescribeRebirthTarget(BrowseNode node, IReadOnlyList<string> ancestorPath)
{
var depth = ancestorPath.Count;
var name = node.DisplayName;
// Sparkplug has no device- or metric-scoped rebirth: an NCMD addresses an EDGE NODE, so a
// deeper selection resolves upward. Say so, rather than letting the operator assume the
// narrower scope they clicked.
if (node.Kind == BrowseNodeKind.Leaf && depth >= 2)
{
return new RebirthTarget(
node.NodeId,
$"edge node {ancestorPath[1]}",
$"Selected the metric '{name}'. Sparkplug addresses rebirth at the edge node, so "
+ $"{ancestorPath[1]} republishes its whole metric set.",
IsGroup: false);
}
return depth switch
{
0 => new RebirthTarget(
node.NodeId,
$"group {name}",
$"Every edge node observed in group '{name}' is asked to republish.",
IsGroup: true),
1 => new RebirthTarget(
node.NodeId,
$"edge node {name}",
$"Edge node '{name}' in group '{ancestorPath[0]}' republishes its whole metric set.",
IsGroup: false),
2 => new RebirthTarget(
node.NodeId,
$"edge node {ancestorPath[1]}",
$"Selected the device '{name}'. Sparkplug addresses rebirth at the edge node, so "
+ $"{ancestorPath[1]} republishes its whole metric set.",
IsGroup: false),
_ => new RebirthTarget(node.NodeId, "the selected node", "", IsGroup: false),
};
}
/// <summary>Arms the confirm step. Publishing is deliberately two clicks — it reaches a live plant broker.</summary>
private void BeginRebirth()
{
_rebirthOutcome = null;
_rebirthError = null;
_rebirthConfirming = true;
}
/// <summary>Disarms the confirm step without publishing anything.</summary>
private void CancelRebirth() => _rebirthConfirming = false;
/// <summary>
/// The one browse action that publishes. Exceptions are SHOWN, not swallowed: a refusal (an
/// over-wide group scope, a missing DriverOperator grant, an unresolvable scope) is exactly what
/// the operator needs to read, and nothing was published in any of those cases.
/// </summary>
private async Task ConfirmRebirthAsync()
{
if (_rebirthTarget is null) { return; }
_rebirthBusy = true;
_rebirthOutcome = null;
_rebirthError = null;
StateHasChanged();
try
{
var published = await BrowserService.RequestRebirthAsync(_token, _rebirthTarget.Scope, default);
_rebirthOutcome =
$"Rebirth requested for {_rebirthTarget.Kind} — {published} command"
+ (published == 1 ? "" : "s")
+ " published. Re-expand the tree in a few seconds to see the republished metrics.";
_rebirthConfirming = false;
}
catch (Exception ex)
{
_rebirthError = ex.Message;
}
finally
{
_rebirthBusy = false;
StateHasChanged();
}
}
/// <summary>
/// Re-reads the root of the open session's observed tree. Only the tree is rebuilt: the session,
/// its broker connection and everything it has recorded are untouched, so this is strictly a
/// re-render and never re-observes from scratch.
/// </summary>
/// <remarks>
/// The tag <b>selection</b> is deliberately kept — <c>_selectedIds</c> is keyed by browse node id
/// and a refresh does not renumber anything, so an operator who ticked leaves, waited for more
/// of the plant to appear, and refreshed does not silently lose the ticks. The rebirth scope IS
/// cleared, because the node it pointed at may no longer be in the rebuilt tree and a stale
/// armed scope is exactly the thing that panel's two-click confirm exists to prevent.
/// </remarks>
private void RefreshTree()
{
_treeGeneration++;
ResetRebirthState();
}
/// <summary>Clears every rebirth-panel field (modal re-open, browser close).</summary>
private void ResetRebirthState()
{
_rebirthTarget = null;
_rebirthConfirming = false;
_rebirthBusy = false;
_rebirthOutcome = null;
_rebirthError = null;
}
private async Task CommitAsync()
@@ -288,10 +575,21 @@
StateHasChanged();
try
{
// Refuse a selection the driver could not bind, IN WORDS — for a tuple-addressed driver
// (MQTT/Sparkplug) a leaf whose attribute lookup returned nothing carries no address, and
// committing it would produce a tag that deploys clean and then reports BadNodeIdUnknown
// forever with nothing to point at.
_commitErrors = _selected.Values
.Select(s => RawBrowseCommitMapper.DescribeUncommittableLeaf(_driverType, s.Name, s.AddressFields))
.Where(e => e is not null)
.Select(e => e!)
.ToList();
if (_commitErrors.Count > 0) { return; }
var rows = _selected.Values
.Select(s => RawBrowseCommitMapper.MapLeaf(
_driverType, s.NodeId, s.Name, s.DriverDataType, DefaultDataType,
_groupPrefix, s.FolderPath, _createGroups))
_groupPrefix, s.FolderPath, _createGroups, s.AddressFields))
.ToList();
var outcome = await Svc.ImportTagsAsync(_effectiveDeviceId, rows);
@@ -314,6 +612,8 @@
{
var t = _token;
_token = Guid.Empty;
_canRebirth = false;
ResetRebirthState();
Visible = false;
_open = false;
await VisibleChanged.InvokeAsync(false);
@@ -335,5 +635,19 @@
return ValueTask.CompletedTask;
}
private sealed record SelectedLeaf(string NodeId, string Name, string? DriverDataType, IReadOnlyList<string> FolderPath);
/// <param name="AddressFields">The structured address the browse session stated for this leaf, for a
/// driver whose binding is a tuple rather than the NodeId itself; null for every other driver.</param>
private sealed record SelectedLeaf(
string NodeId,
string Name,
string? DriverDataType,
IReadOnlyList<string> FolderPath,
IReadOnlyDictionary<string, string>? AddressFields = null);
/// <summary>A described Request-rebirth scope: what gets published, and to what.</summary>
/// <param name="Scope">The browse node id handed to the session verbatim.</param>
/// <param name="Kind">What the scope resolves to, in operator words (e.g. "edge node EdgeA").</param>
/// <param name="Detail">The one-line consequence, spelled out before the confirm step.</param>
/// <param name="IsGroup">True for a whole-group fan-out — the bounded, all-or-nothing case.</param>
private sealed record RebirthTarget(string Scope, string Kind, string Detail, bool IsGroup);
}
@@ -67,6 +67,7 @@
("TwinCAT", DriverTypeNames.TwinCAT),
("FOCAS", DriverTypeNames.FOCAS),
("OpcUaClient", DriverTypeNames.OpcUaClient),
("MQTT", DriverTypeNames.Mqtt),
("Galaxy", DriverTypeNames.Galaxy),
("Sql", DriverTypeNames.Sql),
("Calculation", "Calculation"),
@@ -0,0 +1,212 @@
@* Typed TagConfig editor for the Mqtt driver. Same (ConfigJson/ConfigJsonChanged/DriverType/
GetDriverConfigJson) parameter shape every typed editor takes; the last two are accepted for
dispatch uniformity and unused here (MQTT has no address-builder picker — topics are discovered
through the /raw browse tree, not composed from parts).
The shape switch is real (it drives which field group renders and what Validate() applies) but it
is a UI-only choice derived from the blob, never serialised — the driver takes Plain vs SparkplugB
from its DRIVER config, and the tag blob has no 'mode' key. A SparkplugB tag survives a save→reopen
because its descriptor keys (groupId/edgeNodeId/deviceId/metricName) re-infer the mode on load —
there is nothing else that needs to persist. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
@using ZB.MOM.WW.OtOpcUa.Driver.Mqtt
<div class="row g-2">
<div class="col-md-4">
<label class="form-label" for="mqtt-mode">Tag shape</label>
<select id="mqtt-mode" class="form-select form-select-sm" value="@_m.Mode"
@onchange="@(e => Update(() => _m.Mode = ParseEnum(e.Value, MqttMode.Plain)))">
@foreach (var v in Enum.GetValues<MqttMode>()) { <option value="@v">@v</option> }
</select>
</div>
@if (_m.Mode == MqttMode.Plain)
{
<div class="col-md-8">
<label class="form-label" for="mqtt-topic">Topic</label>
<input id="mqtt-topic" type="text" class="form-control form-control-sm mono"
placeholder="factory/oven/temp" value="@_m.Topic"
@onchange="@(e => Update(() => _m.Topic = e.Value?.ToString() ?? ""))" />
<div class="form-text">Concrete topic — MQTT wildcards (+ / #) are not valid for a tag.</div>
</div>
<div class="col-md-4">
<label class="form-label" for="mqtt-payload-format">Payload format</label>
<select id="mqtt-payload-format" class="form-select form-select-sm" value="@_m.PayloadFormat"
@onchange="@(e => Update(() => _m.PayloadFormat = ParseEnum(e.Value, MqttPayloadFormat.Json)))">
@foreach (var v in Enum.GetValues<MqttPayloadFormat>()) { <option value="@v">@v</option> }
</select>
</div>
@if (_m.PayloadFormat == MqttPayloadFormat.Json)
{
<div class="col-md-4">
<label class="form-label" for="mqtt-json-path">JSON path</label>
<input id="mqtt-json-path" type="text" class="form-control form-control-sm mono"
placeholder="$.value" value="@_m.JsonPath"
@onchange="@(e => Update(() => _m.JsonPath = e.Value?.ToString() ?? ""))" />
<div class="form-text">Use <code>$</code> for the whole document.</div>
</div>
}
<div class="col-md-4">
<label class="form-label" for="mqtt-data-type">Data type</label>
@* The driver's own DriverDataType members — authoring anything outside this set produces a
blob the driver's strict parser rejects at deploy (there is no 'Double'; it is Float64). *@
<select id="mqtt-data-type" class="form-select form-select-sm" value="@_m.DataType"
@onchange="@(e => Update(() => _m.DataType = ParseEnum(e.Value, DriverDataType.String)))">
@foreach (var v in Enum.GetValues<DriverDataType>()) { <option value="@v">@v</option> }
</select>
</div>
<div class="col-md-4">
<label class="form-label" for="mqtt-qos">QoS</label>
<select id="mqtt-qos" class="form-select form-select-sm" value="@(_m.Qos?.ToString() ?? "")"
@onchange="@(e => Update(() => _m.Qos = ParseNullableInt(e.Value)))">
<option value="">(driver default)</option>
<option value="0">0 — at most once</option>
<option value="1">1 — at least once</option>
<option value="2">2 — exactly once</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label" for="mqtt-retain-seed">Retained-message seed</label>
<select id="mqtt-retain-seed" class="form-select form-select-sm"
value="@(_m.RetainSeed is null ? "" : _m.RetainSeed.Value ? "true" : "false")"
@onchange="@(e => Update(() => _m.RetainSeed = ParseNullableBool(e.Value)))">
<option value="">(driver default)</option>
<option value="true">Seed from retained message</option>
<option value="false">Wait for a live publish</option>
</select>
</div>
}
else
{
<div class="col-md-4">
<label class="form-label" for="mqtt-sp-group">Group ID</label>
<input id="mqtt-sp-group" type="text" class="form-control form-control-sm mono"
placeholder="Plant1" value="@_m.GroupId"
@onchange="@(e => Update(() => _m.GroupId = e.Value?.ToString() ?? ""))" />
<div class="form-text">The driver subscribes <code>spBv1.0/@(string.IsNullOrEmpty(_m.GroupId) ? "{GroupId}" : _m.GroupId)/#</code>. No <code>/</code>, <code>+</code>, or <code>#</code>.</div>
</div>
<div class="col-md-4">
<label class="form-label" for="mqtt-sp-node">Edge node ID</label>
<input id="mqtt-sp-node" type="text" class="form-control form-control-sm mono"
placeholder="EdgeA" value="@_m.EdgeNodeId"
@onchange="@(e => Update(() => _m.EdgeNodeId = e.Value?.ToString() ?? ""))" />
<div class="form-text">The publishing edge node's id.</div>
</div>
<div class="col-md-4">
<label class="form-label" for="mqtt-sp-device">Device ID <span class="text-muted">(optional)</span></label>
<input id="mqtt-sp-device" type="text" class="form-control form-control-sm mono"
placeholder="(node-level metric)" value="@_m.DeviceId"
@onchange="@(e => Update(() => _m.DeviceId = e.Value?.ToString() ?? ""))" />
<div class="form-text">Leave blank for a metric published by the edge node itself.</div>
</div>
<div class="col-md-6">
<label class="form-label" for="mqtt-sp-metric">Metric name</label>
<input id="mqtt-sp-metric" type="text" class="form-control form-control-sm mono"
placeholder="Node Control/Rebirth" value="@_m.MetricName"
@onchange="@(e => Update(() => _m.MetricName = e.Value?.ToString() ?? ""))" />
<div class="form-text">The metric's stable name from the birth certificate — unlike the ids above, this MAY contain <code>/</code> (e.g. <code>Properties/Hardware Make</code>).</div>
</div>
<div class="col-md-3">
<label class="form-label" for="mqtt-sp-data-type">Data type override</label>
@* Same DriverDataType set as Plain's field — the factory reads a Sparkplug 'dataType' key as
a DriverDataType override, not the raw wire SparkplugDataType. Absent ⇒ take whatever type
the birth certificate declares (the driver's UntilStable discovery). *@
<select id="mqtt-sp-data-type" class="form-select form-select-sm" value="@(_m.MetricDataType?.ToString() ?? "")"
@onchange="@(e => Update(() => _m.MetricDataType = ParseNullableEnum<DriverDataType>(e.Value)))">
<option value="">(from birth certificate)</option>
@foreach (var v in Enum.GetValues<DriverDataType>()) { <option value="@v">@v</option> }
</select>
</div>
<div class="col-md-3">
<label class="form-label" for="mqtt-sp-qos">QoS</label>
<select id="mqtt-sp-qos" class="form-select form-select-sm" value="@(_m.Qos?.ToString() ?? "")"
@onchange="@(e => Update(() => _m.Qos = ParseNullableInt(e.Value)))">
<option value="">(driver default)</option>
<option value="0">0 — at most once</option>
<option value="1">1 — at least once</option>
<option value="2">2 — exactly once</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label" for="mqtt-sp-retain-seed">Retained-message seed</label>
<select id="mqtt-sp-retain-seed" class="form-select form-select-sm"
value="@(_m.RetainSeed is null ? "" : _m.RetainSeed.Value ? "true" : "false")"
@onchange="@(e => Update(() => _m.RetainSeed = ParseNullableBool(e.Value)))">
<option value="">(driver default)</option>
<option value="true">Seed from retained message</option>
<option value="false">Wait for a live publish</option>
</select>
</div>
}
@if (_validationError is not null)
{
<div class="col-12"><div class="text-danger small">@_validationError</div></div>
}
</div>
@code {
/// <summary>The tag's TagConfig JSON, owned by the host modal.</summary>
[Parameter] public string? ConfigJson { get; set; }
/// <summary>Raised with the re-serialised TagConfig JSON after every field edit.</summary>
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
/// <summary>DriverType of the owning driver — accepted for dispatch uniformity; unused here.</summary>
[Parameter] public string DriverType { get; set; } = "";
/// <summary>Live accessor for the owning driver's DriverConfig JSON — accepted for dispatch uniformity; unused here.</summary>
[Parameter] public Func<string> GetDriverConfigJson { get; set; } = () => "{}";
private MqttTagConfigModel _m = new();
private string? _lastConfigJson;
private string? _validationError;
// Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render
// (Blazor Server live-status pushes do this) can't reset the user's in-progress edits.
protected override void OnParametersSet()
{
if (ConfigJson == _lastConfigJson) { return; }
_lastConfigJson = ConfigJson;
_m = MqttTagConfigModel.FromJson(ConfigJson);
_validationError = _m.Validate();
}
// TryParse so a bad/empty change value can never throw into the Blazor circuit — it falls back.
private static TEnum ParseEnum<TEnum>(object? v, TEnum fallback) where TEnum : struct, Enum
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : fallback;
// "" (the "(from birth certificate)" sentinel option) ⇒ null ⇒ the key is omitted.
private static TEnum? ParseNullableEnum<TEnum>(object? v) where TEnum : struct, Enum
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : null;
// "" ⇒ null ⇒ the key is omitted and the driver's own default applies.
private static int? ParseNullableInt(object? v)
=> int.TryParse(v?.ToString(), out var i) ? i : null;
private static bool? ParseNullableBool(object? v)
=> bool.TryParse(v?.ToString(), out var b) ? b : null;
private async Task Update(Action apply)
{
apply();
_validationError = _m.Validate();
var json = _m.ToJson();
// Keep the OnParametersSet guard in step with what we just emitted, so the host echoing the new
// JSON straight back as a parameter cannot re-parse and clobber an in-progress edit.
//
// WHY THIS DIVERGES FROM THE MODBUS TEMPLATE (which does not do this): the general landmine is a
// DERIVED, NON-PERSISTED UI FIELD INFERRED FROM BLOB CONTENT. Mode is exactly that — it is
// re-inferred by FromJson from the Sparkplug keys and never serialised, so a plain re-parse of
// our own output would silently reset the operator's shape selection to Plain on the very next
// render. Modbus has no such field, which is why it needs no guard. Task 24's Sparkplug field
// group will be tempted to add more of them; each one needs this guard to hold.
//
// TRADE (does not manifest today): the editor will not pick up a change the HOST makes to the
// JSON while it is open. That is safe only because RawTagModal/RawManualTagEntryModal mutate
// _form.TagConfig exclusively through this callback. A host-side feature that rewrites the blob
// out-of-band — a "reformat JSON" action, a bulk address re-pick — would reintroduce staleness
// here and must re-key the guard (e.g. compare against a host-supplied revision, not the text).
_lastConfigJson = json;
await ConfigJsonChanged.InvokeAsync(json);
}
}