fix(mqtt): browse-commit writes a bindable address + a Request-rebirth affordance
Two gaps the Task 23/24 review found, both blocking Task 26's live gate.
Gap 1 — browse-commit produced a silently dead MQTT tag. RawBrowseCommitMapper
had no `Mqtt` case, so a committed leaf fell through to the generic
`{"address": …}` key. Neither MqttTagDefinitionFactory entry point reads
`address`: the tag deployed clean and reported BadNodeIdUnknown forever, with
no signal at commit time. Predates Task 23 (Plain was affected too), but Task 23
built the Sparkplug metric tree precisely so an operator could browse and commit
a binding.
The address is a DESCRIPTOR, not a reference string, and it cannot be recovered
from the browse node id: `{group}/{node}[/{device}]::{metric}` where a metric
name legitimately contains `/` (`Node Control/Rebirth`) — the ambiguity
MetricSeparator's remarks already name. So the session STATES it, via a new
`AttributeInfo.AddressFields` seam, and the mapper reads it. Which keys are
emitted is also how the mapper learns Plain vs Sparkplug — the driver type
reaching it is just `Mqtt`, and the mode lives on the driver config. Key names
are single-sourced in the new `MqttTagConfigKeys` (producer, mapper, factory),
with the literals pinned by a test so a symmetric rename cannot silently unbind
already-persisted blobs. A leaf with no stated address is refused at commit in
words rather than committed dead.
Gap 2 — RequestRebirthAsync had no UI. Task 23 shipped it backend-only; Task 26
step 1 assumes the button, and Task 25's runbook notes the picker tree stays
empty until a birth lands, so it is the only way to fill it on demand. Added to
the /raw browse modal: scope = the clicked tree node (device/metric resolve up to
their edge node, group fans out and is refused whole past 32), an explicit
two-click confirm naming the resolved scope and its consequence, the outcome or
the refusal shown rather than swallowed. Offered only for a Sparkplug session
(new `IRebirthCapableBrowseSession.RebirthAvailable` — one session class serves
both modes, so a type test alone would draw a button that can only throw) and
only to a DriverOperator; the server-side gate remains the boundary.
Tests: MQTT 545 → 581, AdminUI 781 → 800. The round-trip tests feed the emitted
blob to the REAL factory and were falsified by mutating the emitted key name.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -140,6 +140,10 @@ public sealed class BrowserSessionService(
|
||||
return published;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRequestRebirth(Guid token) =>
|
||||
registry.TryGet(token, out var session) && session is IRebirthCapableBrowseSession { RebirthAvailable: true };
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task CloseAsync(Guid token)
|
||||
{
|
||||
|
||||
@@ -71,6 +71,21 @@ public interface IBrowserSessionService
|
||||
/// <exception cref="NotSupportedException">This session's driver has no re-announce action.</exception>
|
||||
Task<int> RequestRebirthAsync(Guid token, string scope, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// True when the open session behind <paramref name="token"/> can actually re-announce — i.e.
|
||||
/// <see cref="RequestRebirthAsync"/> is worth offering. Cheap (no I/O); never throws; false for
|
||||
/// an unknown/reaped token.
|
||||
/// </summary>
|
||||
/// <param name="token">Registry handle for the open browse session.</param>
|
||||
/// <returns>True when the picker should draw a Request-rebirth affordance.</returns>
|
||||
/// <remarks>
|
||||
/// A capability probe, <b>not</b> an authorization check — the DriverOperator gate lives on
|
||||
/// <see cref="RequestRebirthAsync"/> and fails closed there. The picker gates on both: this
|
||||
/// decides whether the action exists at all (a plain-MQTT window has none), the policy decides
|
||||
/// whether this operator may fire it.
|
||||
/// </remarks>
|
||||
bool CanRequestRebirth(Guid token);
|
||||
|
||||
/// <summary>Removes the session from the registry and disposes it. No-op for unknown tokens.</summary>
|
||||
/// <param name="token">Registry handle for the browse session to close.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
|
||||
+10
-2
@@ -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)
|
||||
@@ -142,7 +150,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<a href="#" @onclick="@(() => SelectAsync(item))" @onclick:preventDefault
|
||||
<a href="#" @onclick="@(() => SelectAsync(item, ancestorPath))" @onclick:preventDefault
|
||||
class="text-decoration-none mono small">@item.Node.DisplayName</a>
|
||||
}
|
||||
@if (item.Node.Kind == BrowseNodeKind.Leaf)
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
@@ -77,7 +81,89 @@
|
||||
</div>
|
||||
|
||||
<DriverBrowseTree SessionToken="_token" MultiSelect="true"
|
||||
SelectedLeafIds="_selectedIds" OnLeafToggled="OnLeafToggledAsync" />
|
||||
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 +202,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 +245,26 @@
|
||||
private bool _busy;
|
||||
private List<string> _commitErrors = new();
|
||||
|
||||
// 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 +293,8 @@
|
||||
_groupPrefix = null;
|
||||
_canBrowse = false;
|
||||
_disabledReason = null;
|
||||
ResetRebirthState();
|
||||
_canRebirth = false;
|
||||
|
||||
_resolving = true;
|
||||
StateHasChanged();
|
||||
@@ -238,7 +348,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 +379,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 +387,146 @@
|
||||
{
|
||||
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>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 +536,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 +573,8 @@
|
||||
{
|
||||
var t = _token;
|
||||
_token = Guid.Empty;
|
||||
_canRebirth = false;
|
||||
ResetRebirthState();
|
||||
Visible = false;
|
||||
_open = false;
|
||||
await VisibleChanged.InvokeAsync(false);
|
||||
@@ -335,5 +596,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);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
@@ -33,6 +34,9 @@ public static class RawBrowseCommitMapper
|
||||
/// <param name="folderPath">The captured browse-folder nesting above the leaf (root→parent display
|
||||
/// names); mirrored onto nested TagGroups only when <paramref name="createGroups"/> is true.</param>
|
||||
/// <param name="createGroups">When true, mirror <paramref name="folderPath"/> as nested TagGroups.</param>
|
||||
/// <param name="addressFields">The leaf's structured address (<see cref="AttributeInfo.AddressFields"/>),
|
||||
/// for a driver whose binding is a tuple rather than a single reference string; null for every driver
|
||||
/// whose address is the <paramref name="fullName"/> itself.</param>
|
||||
/// <returns>The assembled import row.</returns>
|
||||
public static RawTagImportRow MapLeaf(
|
||||
string driverType,
|
||||
@@ -42,10 +46,11 @@ public static class RawBrowseCommitMapper
|
||||
string defaultDataType,
|
||||
string? groupPrefix,
|
||||
IReadOnlyList<string>? folderPath,
|
||||
bool createGroups)
|
||||
bool createGroups,
|
||||
IReadOnlyDictionary<string, string>? addressFields = null)
|
||||
{
|
||||
var dataType = MapDataType(driverDataType) ?? defaultDataType;
|
||||
var tagConfig = BuildTagConfig(driverType, fullName);
|
||||
var tagConfig = BuildTagConfig(driverType, fullName, addressFields);
|
||||
var mirrored = createGroups && folderPath is { Count: > 0 }
|
||||
? string.Join(RawPaths.Separator, folderPath)
|
||||
: null;
|
||||
@@ -96,13 +101,25 @@ public static class RawBrowseCommitMapper
|
||||
/// address field to <paramref name="fullName"/> and leaving every other field at its model default. Reuses
|
||||
/// the <c><Driver>TagConfigModel</c> for driver types that have a typed editor; the model-less Galaxy
|
||||
/// driver gets the canonical camelCase <c>attributeRef</c> key directly.
|
||||
/// <para>
|
||||
/// <b>MQTT is the one driver whose address is not a single string</b> — a Sparkplug tag binds by a
|
||||
/// <c>(group, edgeNode, device?, metric)</c> tuple — so it is built from the browse session's stated
|
||||
/// <paramref name="addressFields"/> instead. See <see cref="BuildMqttTagConfig"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="driverType">The owning device's driver type.</param>
|
||||
/// <param name="fullName">The leaf's driver-side full reference to write into the address field.</param>
|
||||
/// <param name="addressFields">The leaf's structured address, when the driver binds by a tuple —
|
||||
/// see <see cref="BuildMqttTagConfig"/>. Ignored by every single-reference driver.</param>
|
||||
/// <returns>The serialised driver-typed <c>TagConfig</c> JSON.</returns>
|
||||
public static string BuildTagConfig(string driverType, string fullName)
|
||||
public static string BuildTagConfig(
|
||||
string driverType,
|
||||
string fullName,
|
||||
IReadOnlyDictionary<string, string>? addressFields = null)
|
||||
{
|
||||
var address = fullName ?? "";
|
||||
if (Is(driverType, DriverTypeNames.Mqtt))
|
||||
return BuildMqttTagConfig(address, addressFields);
|
||||
if (Is(driverType, DriverTypeNames.OpcUaClient))
|
||||
return new OpcUaClientTagConfigModel { NodeId = address }.ToJson();
|
||||
if (Is(driverType, DriverTypeNames.AbCip))
|
||||
@@ -123,6 +140,103 @@ public static class RawBrowseCommitMapper
|
||||
return WriteSingleKey("address", address);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the <c>TagConfig</c> for a browse-committed <b>MQTT</b> leaf, whose address is a
|
||||
/// <i>descriptor</i> rather than a single reference string: <c>topic</c> in Plain mode, and the
|
||||
/// <c>groupId</c>/<c>edgeNodeId</c>/<c>deviceId?</c>/<c>metricName</c> tuple in Sparkplug B mode.
|
||||
/// </summary>
|
||||
/// <param name="fullName">The leaf's browse node id — the Plain-mode fallback address only.</param>
|
||||
/// <param name="addressFields">The structured address the browse session stated.</param>
|
||||
/// <returns>The serialised <c>TagConfig</c> JSON.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The address is read from <paramref name="addressFields"/>, never parsed out of
|
||||
/// <paramref name="fullName"/>.</b> A Sparkplug browse node id is
|
||||
/// <c>{group}/{node}[/{device}]::{metric}</c>, and a metric name legitimately contains <c>/</c>
|
||||
/// (<c>Node Control/Rebirth</c>) — so splitting the id cannot recover the tuple in general, and a
|
||||
/// mapper that guessed would silently bind the wrong metric. The session that decoded the birth
|
||||
/// already holds the decomposition and hands it over; this method only picks the keys it knows.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Plain vs Sparkplug is likewise stated, not inferred</b> — the driver <i>type</i> reaching
|
||||
/// here is just <c>Mqtt</c>, and the mode lives on the driver config, not on the tag. The
|
||||
/// producing session knows its own mode and says so by which keys it emits: a
|
||||
/// <c>metricName</c> ⇒ Sparkplug, a <c>topic</c> ⇒ Plain. (The blob deliberately carries no
|
||||
/// <c>mode</c> key: <c>MqttTagDefinitionFactory</c> takes the shape from the driver's mode and
|
||||
/// would ignore one, and <c>MqttTagConfigModel</c> re-infers it from these same keys.)
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Keys are <b>whitelisted</b>, not splatted: these values came off a broker, and everything
|
||||
/// beyond the address (payload format, JSONPath, QoS, dataType) stays at the driver's own
|
||||
/// defaults for the operator to author afterwards. A leaf with no stated address at all is
|
||||
/// rejected before this is reached — see <see cref="DescribeUncommittableLeaf"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static string BuildMqttTagConfig(string fullName, IReadOnlyDictionary<string, string>? addressFields)
|
||||
{
|
||||
if (TryGetField(addressFields, MqttTagConfigKeys.MetricName) is { } metricName)
|
||||
{
|
||||
var o = new JsonObject
|
||||
{
|
||||
[MqttTagConfigKeys.GroupId] = TryGetField(addressFields, MqttTagConfigKeys.GroupId) ?? "",
|
||||
[MqttTagConfigKeys.EdgeNodeId] = TryGetField(addressFields, MqttTagConfigKeys.EdgeNodeId) ?? "",
|
||||
[MqttTagConfigKeys.MetricName] = metricName,
|
||||
};
|
||||
|
||||
// Absent, not blank, for a node-level metric — the two describe the same scope to the
|
||||
// factory, but only the absent form says "this metric has no device" to a reader.
|
||||
if (TryGetField(addressFields, MqttTagConfigKeys.DeviceId) is { } deviceId)
|
||||
o[MqttTagConfigKeys.DeviceId] = deviceId;
|
||||
|
||||
return o.ToJsonString();
|
||||
}
|
||||
|
||||
// Plain: the session states the topic; the node id is the same value and is the fallback for a
|
||||
// session that stated nothing (a shape DescribeUncommittableLeaf refuses upstream).
|
||||
return WriteSingleKey(
|
||||
MqttTagConfigKeys.Topic,
|
||||
TryGetField(addressFields, MqttTagConfigKeys.Topic) ?? fullName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes why a selected browse leaf cannot be committed as a working tag, or <c>null</c> when it
|
||||
/// can. The AdminUI calls this before mapping so an unbindable selection fails <b>at commit, in
|
||||
/// words</b>.
|
||||
/// </summary>
|
||||
/// <param name="driverType">The owning device's driver type.</param>
|
||||
/// <param name="browseName">The leaf's browse name, for the message.</param>
|
||||
/// <param name="addressFields">The structured address the browse session stated, if any.</param>
|
||||
/// <returns>The operator-facing reason, or <c>null</c> when the leaf is committable.</returns>
|
||||
/// <remarks>
|
||||
/// Only MQTT can fail this today, and only in one shape: a leaf whose attribute lookup returned
|
||||
/// nothing, so no address was stated. Committing it anyway is the defect this whole seam exists
|
||||
/// to close — a Sparkplug tuple cannot be reconstructed from the node id, so the row would deploy
|
||||
/// clean and then report <c>BadNodeIdUnknown</c> forever with nothing to point at. Every
|
||||
/// single-reference driver is unaffected: its address IS the node id.
|
||||
/// </remarks>
|
||||
public static string? DescribeUncommittableLeaf(
|
||||
string driverType,
|
||||
string browseName,
|
||||
IReadOnlyDictionary<string, string>? addressFields)
|
||||
{
|
||||
if (!Is(driverType, DriverTypeNames.Mqtt)) return null;
|
||||
if (TryGetField(addressFields, MqttTagConfigKeys.MetricName) is not null) return null;
|
||||
if (TryGetField(addressFields, MqttTagConfigKeys.Topic) is not null) return null;
|
||||
|
||||
return $"'{browseName}' could not be committed: the browse session reported no MQTT address for it "
|
||||
+ "(its attribute lookup returned nothing). Re-open the browser and re-select it, or author "
|
||||
+ "the tag manually.";
|
||||
}
|
||||
|
||||
/// <summary>Reads one non-blank address field, or <c>null</c> when it is absent or blank.</summary>
|
||||
/// <param name="fields">The stated address fields, or null.</param>
|
||||
/// <param name="key">The field to read.</param>
|
||||
/// <returns>The trimmed value, or <c>null</c>.</returns>
|
||||
private static string? TryGetField(IReadOnlyDictionary<string, string>? fields, string key)
|
||||
=> fields is not null && fields.TryGetValue(key, out var v) && !string.IsNullOrWhiteSpace(v)
|
||||
? v.Trim()
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// Combines a target-group path prefix with an optional mirrored sub-path, matching the WP5 CSV import
|
||||
/// combine semantics: blank collapses to null, and a present prefix + suffix join with the RawPath
|
||||
|
||||
Reference in New Issue
Block a user