Merge branch 'feat/mqtt-sparkplug-driver'
# 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:
+61
@@ -0,0 +1,61 @@
|
||||
@* MQTT device form — informational only. An MQTT driver instance holds exactly one broker session,
|
||||
configured on the DRIVER (MqttDriverForm), so there is no per-device connection endpoint to author;
|
||||
devices are pure organisational grouping in the RawPath. The DeviceConfig is round-tripped verbatim,
|
||||
and Test-connect uses the driver's broker settings via the merged config. Same shape as
|
||||
GalaxyDeviceForm.
|
||||
|
||||
The warning below fires only for a pre-form deployment that authored the connection on the device:
|
||||
DriverDeviceConfigMerger merges a SOLE device's keys up over the driver's, so those keys keep
|
||||
winning over what the driver form shows. *@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
|
||||
|
||||
<section class="panel notice rise" style="animation-delay:.04s">
|
||||
<div class="panel-head">Connection</div>
|
||||
<div style="padding:1rem" class="text-muted">
|
||||
MQTT connects to a single broker configured on the <strong>driver</strong> — there is no per-device
|
||||
endpoint to author here. Edit host / port / TLS / credentials on the driver's config. Devices under an
|
||||
MQTT driver exist purely to group tags in the RawPath.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (_model.LegacyConnectionKeys.Count > 0)
|
||||
{
|
||||
<section class="panel rise mt-3" style="animation-delay:.06s;border-color:var(--alert)">
|
||||
<div class="panel-head">Legacy connection keys on this device</div>
|
||||
<div style="padding:1rem">
|
||||
<p class="mb-2 small">
|
||||
This device's config still carries broker connection keys from before the driver form existed:
|
||||
<span class="mono">@string.Join(", ", _model.LegacyConnectionKeys)</span>.
|
||||
</p>
|
||||
<p class="mb-0 small">
|
||||
While this driver has <strong>exactly one</strong> device these keys are merged up and
|
||||
<strong>override</strong> the driver form's values — so the driver form may not show what is
|
||||
actually in effect. They are preserved (nothing is dropped), but the durable fix is to author the
|
||||
connection on the driver and clear these keys: a second device on this driver stops the merge-up
|
||||
entirely and the connection would vanish.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>The device's DeviceConfig JSON (round-tripped verbatim — MQTT has no per-device endpoint).</summary>
|
||||
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
|
||||
/// <summary>Fired when the (preserved) DeviceConfig changes — no-op UI, kept for the modal contract.</summary>
|
||||
[Parameter] public EventCallback<string> DeviceConfigJsonChanged { get; set; }
|
||||
|
||||
private MqttDeviceModel _model = MqttDeviceModel.FromJson(null);
|
||||
private string? _lastParsed;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal))
|
||||
{
|
||||
_model = MqttDeviceModel.FromJson(DeviceConfigJson);
|
||||
_lastParsed = DeviceConfigJson;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Serialises the (preserved) DeviceConfig JSON.</summary>
|
||||
public string GetConfigJson() => _model.ToJson();
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
|
||||
|
||||
/// <summary>
|
||||
/// Device model for the MQTT / Sparkplug B driver. An MQTT driver instance holds exactly <b>one</b>
|
||||
/// broker session, configured on the <b>driver</b> (<c>MqttDriverForm</c>) — so, like Galaxy, there
|
||||
/// is no per-device connection endpoint to author and this model round-trips the <c>DeviceConfig</c>
|
||||
/// JSON verbatim. Devices under an MQTT driver are pure organisational grouping in the RawPath.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why the connection is not authored here.</b> <c>DriverDeviceConfigMerger</c> merges a device's
|
||||
/// keys up to the top level <i>only when the driver has exactly one device</i>. A broker connection
|
||||
/// authored on the device would therefore disappear from the merged blob the moment an operator adds
|
||||
/// a second device — a silent, deploy-time-invisible outage. Driver-level authoring is correct for
|
||||
/// 0, 1 or N devices.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b><see cref="LegacyConnectionKeys"/> exists because that merge-up still happens.</b> A
|
||||
/// pre-form deployment (hand-authored or SQL-seeded, as the P1 live gate had to do) may carry
|
||||
/// <c>Host</c>/<c>Port</c>/… on a sole device's <c>DeviceConfig</c>, and those keys keep winning over
|
||||
/// the driver form's values. They are preserved (never silently dropped) and reported so the device
|
||||
/// form can tell the operator why the driver form's host is not the one in effect.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class MqttDeviceModel
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>MqttDriverOptions</c> connection-surface key names that a legacy <c>DeviceConfig</c> may
|
||||
/// carry and that would merge up over the driver form's values.
|
||||
/// </summary>
|
||||
private static readonly string[] ConnectionKeyNames =
|
||||
[
|
||||
"Host", "Port", "ClientId", "UseTls", "AllowUntrustedServerCertificate",
|
||||
"CaCertificatePath", "Username", "Password", "ProtocolVersion", "CleanSession",
|
||||
"KeepAliveSeconds", "ConnectTimeoutSeconds", "ReconnectMinBackoffSeconds",
|
||||
"ReconnectMaxBackoffSeconds",
|
||||
];
|
||||
|
||||
private System.Text.Json.Nodes.JsonObject _bag = new();
|
||||
|
||||
/// <summary>
|
||||
/// The connection keys this <c>DeviceConfig</c> actually carries, in the casing they were
|
||||
/// authored with — empty for the normal case. Non-empty means a sole-device merge-up will
|
||||
/// override the driver form for those keys.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> LegacyConnectionKeys { get; private set; } = [];
|
||||
|
||||
/// <summary>Loads a model from a <c>DeviceConfig</c> JSON string, retaining every original key.</summary>
|
||||
/// <param name="json">The raw <c>DeviceConfig</c> JSON string, or <c>null</c> for a new/empty device.</param>
|
||||
/// <returns>The populated <see cref="MqttDeviceModel"/>.</returns>
|
||||
public static MqttDeviceModel FromJson(string? json)
|
||||
{
|
||||
var bag = TagConfigJson.ParseOrNew(json);
|
||||
return new MqttDeviceModel
|
||||
{
|
||||
_bag = bag,
|
||||
LegacyConnectionKeys = bag
|
||||
.Select(p => p.Key)
|
||||
.Where(k => ConnectionKeyNames.Contains(k, StringComparer.OrdinalIgnoreCase))
|
||||
.ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Serialises the (preserved) <c>DeviceConfig</c> back to a JSON string.</summary>
|
||||
/// <returns>The serialised <c>DeviceConfig</c> JSON string.</returns>
|
||||
public string ToJson() => TagConfigJson.Serialize(_bag);
|
||||
|
||||
/// <summary>Validation hook; MQTT device rows carry no endpoint, so always valid.</summary>
|
||||
/// <returns><c>null</c> — no per-device endpoint to validate.</returns>
|
||||
public string? Validate() => null;
|
||||
}
|
||||
@@ -65,6 +65,9 @@
|
||||
case DriverTypeNames.Galaxy:
|
||||
<GalaxyDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
|
||||
break;
|
||||
case DriverTypeNames.Mqtt:
|
||||
<MqttDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
|
||||
break;
|
||||
default:
|
||||
<div class="alert alert-warning">No typed device form for driver type <span class="mono">@_driverType</span>.</div>
|
||||
break;
|
||||
|
||||
+23
-5
@@ -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)
|
||||
{
|
||||
|
||||
+19
-3
@@ -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)
|
||||
{
|
||||
|
||||
+331
@@ -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);
|
||||
}
|
||||
}
|
||||
+430
@@ -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">
|
||||
— 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…
|
||||
</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"),
|
||||
|
||||
+212
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user