Five MQTT tracker items — four AdminUI/authoring defects plus the .Browser layering exception. No runtime driver behaviour changes. #513 payloadFormat in a Sparkplug blob. MqttTagConfigModel.ToJson wrote the key unconditionally. Sharper than the issue states: PayloadFormat is the one Plain field with a NON-NULLABLE typed backing, so the write did not preserve a retyped tag's leftover — it INVENTED "payloadFormat":"Json" in a Sparkplug tag that never had one. Now Plain-only; preserve-unknown-keys is pinned unchanged. #509 slashed Sparkplug metric names. The mapper already carried metricName verbatim; only the tag Name (a RawPath segment) tripped RawPaths.ValidateSegment, which refused the batch whole — including Node Control/Rebirth, mandatory on every Sparkplug edge node. ToRawSegment coerces the Name and leaves the address untouched. Coercion is not injective, so DescribeNameCollisions refuses a colliding set BY NAME: silently merging two metrics into one tag would be worse than the refusal it replaces. Applies to every driver, not just MQTT — the argument is about what a Name is, not about who produced it. The sanitiser is pinned against RawPaths.ValidateSegment rather than a copied charset. #510 stale rebirth affordance. _canRebirth was probed once at browse-open and sessions are reaped for idleness, so a modal left open offered an action whose token no longer resolved. Re-probed on arm AND on confirm (the reap can land between the two, and confirm is the one path that publishes to a live broker); the affordance retires with an explanation instead of vanishing or erroring. #514 rebirth on a quiet plant. The tree is birth-driven and births are never retained, so a plant that birthed before the window opened showed an empty tree — and the scope came only from a tree selection, so the one action that would fill the tree could not be armed. The session already accepted a bare {group}/{edgeNode} ("the prime rebirth target"); this adds the affordance. Edge-node scope only, not a group broadcast. The rule is extracted to SparkplugRebirthScope so it is testable — this repo has no bUnit — and is advisory: the session re-resolves and refuses before publishing. #512 .Browser -> .Driver layering. TWO extractions were needed, not the one the issue anticipated: it costed this as "the client-options builder", but the browse session decodes births itself, so moving only that would have left the reference exactly where it was. - MqttClientOptionsFactory (TLS, PEM CA-pin validator, credentials, protocol mapping) -> new leaf .Transport project. NOT .Contracts: that is deliberately transport-free and MqttClientOptions is an MQTTnet type, so .Contracts would hand MQTTnet to ControlPlane. This is the fix the .Browser csproj comment had already prescribed, against the issue's suggestion. - The Sparkplug protocol layer (SparkplugTopic/MessageType, SparkplugCodec, RebirthRequester) -> .Contracts, beside the Tahu wire schema it encodes. Still exactly one copy of the TLS/CA-pin logic; MqttBrowserLayeringTests guards the graph both ways. Measured, not assumed: Driver.Mqtt.dll, Polly.Core and Serilog are now absent from the AdminUI's output. Core.dll remains, because Driver.Sql.Browser has the SAME violation, undocumented — the old comment blamed this browser for Core and was wrong. Filed separately rather than fixed in passing. Full suite green apart from the 7 known pre-existing failures (4 AbLegacy integration, compose profiles not up; 3 Host.IntegrationTests). Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
|
||||
|
||||
/// <summary>
|
||||
/// Validates an operator-typed Sparkplug rebirth scope for the raw browse modal (Gitea #514).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why a typed scope exists at all.</b> The Sparkplug browse tree is <i>birth-driven</i>, and
|
||||
/// births are never retained. On a quiet plant — every edge node birthed long before the browse
|
||||
/// window opened, and is now publishing only NDATA/DDATA — the tree comes up empty. The
|
||||
/// Request-rebirth scope was derived solely from a tree selection, so the one action that would
|
||||
/// populate the tree could not be armed: the action needed the tree, and the tree needed the action.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>This is advisory, not the gate.</b> <c>MqttBrowseSession.ResolveRebirthTargets</c> re-resolves
|
||||
/// the scope and refuses an unusable one <em>before any message leaves</em>, and the
|
||||
/// <c>DriverOperator</c> policy is enforced server-side in <c>BrowserSessionService</c>. This exists
|
||||
/// so the modal does not let an operator arm a two-click confirm that cannot succeed. Being the
|
||||
/// looser of the two is therefore safe; being the stricter one would block a scope the session
|
||||
/// accepts, which is the failure mode the repo's authoring surfaces avoid by convention.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A group-only scope is rejected on purpose.</b> The session resolves a bare group by enumerating
|
||||
/// the edge nodes it has <i>observed</i> — of which an empty tree has none — so it could not help the
|
||||
/// case this input exists for, and on a populated tree it would fan one click across the whole group.
|
||||
/// The tree-selection path still offers a group scope, with its own confirm-time cap and warning.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SparkplugRebirthScope
|
||||
{
|
||||
/// <summary>MQTT wildcards. A rebirth addresses one edge node exactly; a wildcard cannot name one.</summary>
|
||||
private static readonly char[] Wildcards = ['+', '#'];
|
||||
|
||||
/// <summary>
|
||||
/// Describes why <paramref name="raw"/> is not a usable <c>{group}/{edgeNode}</c> rebirth scope.
|
||||
/// </summary>
|
||||
/// <param name="raw">The operator-typed scope, untrimmed; <see langword="null"/> is treated as blank.</param>
|
||||
/// <returns>The operator-facing error text, or <see langword="null"/> when the scope is usable.</returns>
|
||||
public static string? Describe(string? raw)
|
||||
{
|
||||
var scope = (raw ?? "").Trim();
|
||||
if (scope.Length == 0) { return "Enter the edge node as 'group/edgeNode'."; }
|
||||
|
||||
if (scope.IndexOfAny(Wildcards) >= 0)
|
||||
{
|
||||
return "An MQTT wildcard (+ or #) cannot address an edge node; name one exactly.";
|
||||
}
|
||||
|
||||
var segments = scope.Split('/');
|
||||
if (segments.Length != 2 || Array.Exists(segments, string.IsNullOrWhiteSpace))
|
||||
{
|
||||
return "Use exactly 'group/edgeNode' — a group on its own would ask every edge node in it to "
|
||||
+ "republish, which this window cannot enumerate while the tree is empty.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>True when <paramref name="raw"/> is a usable rebirth scope.</summary>
|
||||
/// <param name="raw">The operator-typed scope.</param>
|
||||
/// <returns><see langword="true"/> when <see cref="Describe"/> finds no fault.</returns>
|
||||
public static bool IsUsable(string? raw) => Describe(raw) is null;
|
||||
|
||||
/// <summary>
|
||||
/// Splits a scope already accepted by <see cref="Describe"/> into its two segments.
|
||||
/// </summary>
|
||||
/// <param name="raw">A scope for which <see cref="IsUsable"/> is <see langword="true"/>.</param>
|
||||
/// <returns>The trimmed <c>(group, edgeNode)</c> pair.</returns>
|
||||
/// <exception cref="ArgumentException">The scope is not usable.</exception>
|
||||
public static (string GroupId, string EdgeNodeId) Split(string? raw)
|
||||
{
|
||||
if (Describe(raw) is { } error)
|
||||
{
|
||||
throw new ArgumentException($"'{raw}' is not a usable rebirth scope: {error}", nameof(raw));
|
||||
}
|
||||
|
||||
var segments = raw!.Trim().Split('/');
|
||||
return (segments[0].Trim(), segments[1].Trim());
|
||||
}
|
||||
}
|
||||
@@ -126,9 +126,34 @@
|
||||
|
||||
@if (_rebirthTarget is null)
|
||||
{
|
||||
@* Gitea #514: the tree is BIRTH-driven, and births are never retained. On a
|
||||
quiet plant — every edge node birthed long before this window opened and is
|
||||
now publishing only NDATA/DDATA — the tree is empty, so a tree-selected
|
||||
scope can never be armed and the one action that would fill the tree is
|
||||
unreachable. Naming an edge node directly breaks that cycle. Deliberately
|
||||
an EDGE NODE rather than a group broadcast: a group rebirth is a
|
||||
self-inflicted broker storm on a large plant. *@
|
||||
<div class="small text-muted mt-1">
|
||||
Click a group, edge node, device or metric in the tree above to choose the scope.
|
||||
Click a group, edge node, device or metric in the tree above to choose the
|
||||
scope — or, if the tree is empty because every edge node birthed
|
||||
before this window opened, name one directly:
|
||||
</div>
|
||||
<div class="input-group input-group-sm mt-1">
|
||||
<span class="input-group-text">Edge node</span>
|
||||
<input class="form-control mono" placeholder="group/edgeNode"
|
||||
aria-label="Sparkplug edge-node scope"
|
||||
value="@_manualScope"
|
||||
@oninput="@(e => _manualScope = e.Value?.ToString() ?? "")" />
|
||||
<button type="button" class="btn btn-outline-secondary"
|
||||
disabled="@(ManualScopeError is not null)"
|
||||
@onclick="ApplyManualScope">
|
||||
Use this scope
|
||||
</button>
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(_manualScope) && ManualScopeError is { } manualErr)
|
||||
{
|
||||
<div class="small text-danger mt-1">@manualErr</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -179,6 +204,17 @@
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else if (_rebirthExpired && _canOperate)
|
||||
{
|
||||
@* Gitea #510: the affordance is retired rather than left to fail on click. It is
|
||||
drawn from a capability probed at browse-open, and sessions are reaped for
|
||||
idleness — so a modal left open past the reap used to offer an action whose
|
||||
token no longer resolved. Say what happened instead of silently vanishing. *@
|
||||
<div class="alert alert-warning py-2 mt-2 mb-0 small">
|
||||
This browse session expired while idle, so a rebirth can no longer be requested
|
||||
through it. Close and re-open the browser to request one.
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mt-2 small">
|
||||
<strong>@_selected.Count</strong> tag@(_selected.Count == 1 ? "" : "s") selected.
|
||||
@@ -276,6 +312,18 @@
|
||||
private string? _rebirthOutcome;
|
||||
private string? _rebirthError;
|
||||
|
||||
/// <summary>
|
||||
/// Set when a re-probe finds the session's rebirth capability gone (#510), so the retired
|
||||
/// affordance can be explained rather than silently disappearing.
|
||||
/// </summary>
|
||||
private bool _rebirthExpired;
|
||||
|
||||
/// <summary>
|
||||
/// Operator-typed <c>{group}/{edgeNode}</c> rebirth scope, for the birth-driven-tree deadlock
|
||||
/// (#514). Seeded with the driver's configured group so only the edge node has to be typed.
|
||||
/// </summary>
|
||||
private string _manualScope = "";
|
||||
|
||||
private readonly Dictionary<string, SelectedLeaf> _selected = new(StringComparer.Ordinal);
|
||||
private readonly HashSet<string> _selectedIds = new(StringComparer.Ordinal);
|
||||
|
||||
@@ -316,6 +364,8 @@
|
||||
_disabledReason = null;
|
||||
ResetRebirthState();
|
||||
_canRebirth = false;
|
||||
_rebirthExpired = false;
|
||||
_manualScope = "";
|
||||
|
||||
_resolving = true;
|
||||
StateHasChanged();
|
||||
@@ -373,8 +423,16 @@
|
||||
{
|
||||
_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.
|
||||
// re-announce action at all, so the affordance must not be drawn for it. Re-probed per
|
||||
// click as well — see RebirthStillAvailable (#510).
|
||||
_canRebirth = BrowserService.CanRequestRebirth(_token);
|
||||
_rebirthExpired = false;
|
||||
// Only the edge node should have to be typed (#514): the group is the driver's entire
|
||||
// subscription scope, so it is already in the config this browser was opened with.
|
||||
if (_canRebirth && _manualScope.Length == 0 && TryReadConfiguredGroupId() is { } gid)
|
||||
{
|
||||
_manualScope = gid + "/";
|
||||
}
|
||||
}
|
||||
else { _openError = result.Message; }
|
||||
}
|
||||
@@ -502,9 +560,64 @@
|
||||
{
|
||||
_rebirthOutcome = null;
|
||||
_rebirthError = null;
|
||||
// #510 — the capability was probed once at browse-open; re-probe before offering to act on it.
|
||||
if (!RebirthStillAvailable()) { return; }
|
||||
_rebirthConfirming = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-probes the open session's rebirth capability, retiring the affordance when it has gone
|
||||
/// (Gitea #510).
|
||||
/// </summary>
|
||||
/// <returns><see langword="true"/> when a rebirth can still be requested through this session.</returns>
|
||||
/// <remarks>
|
||||
/// <c>CanRequestRebirth</c> is a cheap, non-throwing, no-I/O probe that is false for a reaped or
|
||||
/// unknown token, so re-running it per click costs nothing. It is a <b>capability</b> probe, not
|
||||
/// an authorization check — the <c>DriverOperator</c> gate stays server-side on
|
||||
/// <c>RequestRebirthAsync</c> and fails closed there, and <c>_canOperate</c> remains the
|
||||
/// defence-in-depth half of the render condition.
|
||||
/// </remarks>
|
||||
private bool RebirthStillAvailable()
|
||||
{
|
||||
if (BrowserService.CanRequestRebirth(_token)) { return true; }
|
||||
|
||||
_canRebirth = false;
|
||||
_rebirthExpired = true;
|
||||
ResetRebirthState();
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes why <see cref="_manualScope"/> is not a usable edge-node scope, or <c>null</c> when
|
||||
/// it is. Mirrors the session's own <c>{group}/{edgeNode}</c> rule (Gitea #514).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Advisory only: <c>MqttBrowseSession.ResolveRebirthTargets</c> re-validates and refuses an
|
||||
/// unresolvable scope <b>before publishing anything</b>, so this exists to keep the operator from
|
||||
/// arming a confirm step that cannot succeed — not as the gate. A group-only scope is rejected on
|
||||
/// purpose: the session would fan out to every observed edge node, and on a tree that is empty
|
||||
/// (the case this input exists for) there are none to fan to.
|
||||
/// </remarks>
|
||||
private string? ManualScopeError => SparkplugRebirthScope.Describe(_manualScope);
|
||||
|
||||
/// <summary>Adopts the typed <c>{group}/{edgeNode}</c> as the rebirth scope (Gitea #514).</summary>
|
||||
private void ApplyManualScope()
|
||||
{
|
||||
if (!RebirthStillAvailable()) { return; }
|
||||
if (!SparkplugRebirthScope.IsUsable(_manualScope)) { return; }
|
||||
|
||||
var (groupId, edgeNodeId) = SparkplugRebirthScope.Split(_manualScope);
|
||||
_rebirthTarget = new RebirthTarget(
|
||||
$"{groupId}/{edgeNodeId}",
|
||||
$"edge node {edgeNodeId}",
|
||||
$"Edge node '{edgeNodeId}' in group '{groupId}' republishes its whole metric set. "
|
||||
+ "This node was named directly, so it need not have appeared in the tree.",
|
||||
IsGroup: false);
|
||||
_rebirthConfirming = false;
|
||||
_rebirthOutcome = null;
|
||||
_rebirthError = null;
|
||||
}
|
||||
|
||||
/// <summary>Disarms the confirm step without publishing anything.</summary>
|
||||
private void CancelRebirth() => _rebirthConfirming = false;
|
||||
|
||||
@@ -516,6 +629,9 @@
|
||||
private async Task ConfirmRebirthAsync()
|
||||
{
|
||||
if (_rebirthTarget is null) { return; }
|
||||
// #510 again, at the last moment before publishing: the reap can land between arming the confirm
|
||||
// step and clicking it, and this is the one path that reaches a live broker.
|
||||
if (!RebirthStillAvailable()) { return; }
|
||||
_rebirthBusy = true;
|
||||
_rebirthOutcome = null;
|
||||
_rebirthError = null;
|
||||
@@ -558,6 +674,34 @@
|
||||
ResetRebirthState();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the driver's configured Sparkplug group id out of the merged config, or <c>null</c> when
|
||||
/// it is absent or the blob is unreadable. Purely a convenience seed for <see cref="_manualScope"/>
|
||||
/// — never a gate, so any failure is simply "no seed".
|
||||
/// </summary>
|
||||
/// <returns>The configured group id, or <c>null</c>.</returns>
|
||||
private string? TryReadConfiguredGroupId()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Case-insensitive: the merged blob is assembled from the driver + device configs and this
|
||||
// is a display convenience, not a binding — it must not depend on which one won.
|
||||
var root = System.Text.Json.Nodes.JsonNode.Parse(_mergedConfig)?.AsObject();
|
||||
var sparkplug = root?
|
||||
.FirstOrDefault(p => string.Equals(p.Key, "Sparkplug", StringComparison.OrdinalIgnoreCase))
|
||||
.Value?.AsObject();
|
||||
var groupId = sparkplug?
|
||||
.FirstOrDefault(p => string.Equals(p.Key, "GroupId", StringComparison.OrdinalIgnoreCase))
|
||||
.Value?.GetValue<string>();
|
||||
|
||||
return string.IsNullOrWhiteSpace(groupId) ? null : groupId.Trim();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Clears every rebirth-panel field (modal re-open, browser close).</summary>
|
||||
private void ResetRebirthState()
|
||||
{
|
||||
@@ -586,12 +730,22 @@
|
||||
.ToList();
|
||||
if (_commitErrors.Count > 0) { return; }
|
||||
|
||||
var rows = _selected.Values
|
||||
var selected = _selected.Values.ToList();
|
||||
var rows = selected
|
||||
.Select(s => RawBrowseCommitMapper.MapLeaf(
|
||||
_driverType, s.NodeId, s.Name, s.DriverDataType, DefaultDataType,
|
||||
_groupPrefix, s.FolderPath, _createGroups, s.AddressFields))
|
||||
.ToList();
|
||||
|
||||
// A leaf name is coerced into a legal RawPath segment (#509), and that coercion is not
|
||||
// injective — 'A/B' and 'A_B' both land on 'A_B'. Refuse the colliding SET by name here:
|
||||
// letting it through would silently merge two distinct metrics into one tag bound to
|
||||
// whichever address survived, which is worse than the outright refusal #509 replaced.
|
||||
_commitErrors = RawBrowseCommitMapper
|
||||
.DescribeNameCollisions(selected.Select(s => s.Name).ToList(), rows)
|
||||
.ToList();
|
||||
if (_commitErrors.Count > 0) { return; }
|
||||
|
||||
var outcome = await Svc.ImportTagsAsync(_effectiveDeviceId, rows);
|
||||
if (outcome.Errors.Count > 0)
|
||||
{
|
||||
@@ -613,6 +767,8 @@
|
||||
var t = _token;
|
||||
_token = Guid.Empty;
|
||||
_canRebirth = false;
|
||||
_rebirthExpired = false;
|
||||
_manualScope = "";
|
||||
ResetRebirthState();
|
||||
Visible = false;
|
||||
_open = false;
|
||||
|
||||
@@ -25,7 +25,10 @@ public static class RawBrowseCommitMapper
|
||||
/// <param name="driverType">The owning device's driver type (a <see cref="DriverTypeNames"/> value).</param>
|
||||
/// <param name="fullName">The leaf's driver-side full reference (the <c>BrowseNode.NodeId</c> = the
|
||||
/// captured <c>DriverAttributeInfo.FullName</c>) — becomes the driver-typed address field.</param>
|
||||
/// <param name="browseName">The leaf's browse name — becomes the raw tag <c>Name</c> (a RawPath segment).</param>
|
||||
/// <param name="browseName">The leaf's browse name. Becomes the raw tag <c>Name</c> after
|
||||
/// <see cref="ToRawSegment"/> coerces it into a legal RawPath segment — a Sparkplug metric name
|
||||
/// legitimately contains <c>/</c> and a Name may not (Gitea #509). The leaf's binding address is
|
||||
/// unaffected: it travels separately into the <c>TagConfig</c>.</param>
|
||||
/// <param name="driverDataType">The leaf's <see cref="DriverDataType"/> name (from the browse
|
||||
/// attribute side-channel), or null when the browser cannot report a type (e.g. the OPC UA Client tree).</param>
|
||||
/// <param name="defaultDataType">The OPC-UA built-in type name to fall back to when
|
||||
@@ -52,6 +55,7 @@ public static class RawBrowseCommitMapper
|
||||
{
|
||||
var dataType = MapDataType(driverDataType) ?? defaultDataType;
|
||||
var tagConfig = BuildTagConfig(driverType, fullName, addressFields);
|
||||
var name = ToRawSegment(browseName);
|
||||
var mirrored = createGroups && folderPath is { Count: > 0 }
|
||||
? string.Join(RawPaths.Separator, folderPath)
|
||||
: null;
|
||||
@@ -61,9 +65,99 @@ public static class RawBrowseCommitMapper
|
||||
// no poll-group (batching is authored later).
|
||||
return new RawTagImportRow(
|
||||
groupPath,
|
||||
new RawTagInput(browseName, dataType, TagAccessLevel.Read, WriteIdempotent: false, PollGroupId: null, tagConfig));
|
||||
new RawTagInput(name, dataType, TagAccessLevel.Read, WriteIdempotent: false, PollGroupId: null, tagConfig));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Coerces a browse leaf's name into a legal RawPath segment (Gitea #509): trims the ends and
|
||||
/// replaces every <see cref="RawPaths.Separator"/> with <see cref="SeparatorReplacement"/>.
|
||||
/// </summary>
|
||||
/// <param name="browseName">The leaf's browse name, as the driver reported it.</param>
|
||||
/// <returns>A <see cref="RawPaths.ValidateSegment"/>-clean segment; blank only if the input was blank.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The Name and the address are two fields, and only the Name is constrained.</b> A tag Name is
|
||||
/// a RawPath segment, so it may not contain <c>/</c>; the driver-side reference it binds by has no
|
||||
/// such rule and is written into the <c>TagConfig</c> untouched by <see cref="BuildTagConfig"/>.
|
||||
/// Refusing the commit outright conflated the two — and it refused a metric the Sparkplug B spec
|
||||
/// makes <em>mandatory</em> on every edge node (<c>Node Control/Rebirth</c>), so the one metric an
|
||||
/// operator is most likely to browse for was the one that could not be committed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>This deliberately covers every driver, not just MQTT.</b> The argument is about what a Name
|
||||
/// is, not about who produced it: any browser whose leaf name carries a <c>/</c> hits the same
|
||||
/// wall, and for all of them the binding address travels separately and survives intact.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The rule set is <see cref="RawPaths.ValidateSegment"/>'s, not an invented one</b> — empty,
|
||||
/// an embedded separator, and leading/trailing whitespace are exactly what it rejects, so trimming
|
||||
/// and replacing is total rather than a best-effort guess. <c>RawBrowseCommitMapperTests</c> pins
|
||||
/// that by asserting the output validates, so a new rule there fails here instead of silently
|
||||
/// letting an illegal Name through to the import.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Sanitising is not injective, so two distinct leaves can land on one Name — see
|
||||
/// <see cref="DescribeNameCollisions"/>, which is why a caller must run that check rather than
|
||||
/// trusting this in isolation.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static string ToRawSegment(string? browseName)
|
||||
{
|
||||
var trimmed = (browseName ?? "").Trim();
|
||||
return trimmed.Length == 0 ? "" : trimmed.Replace(RawPaths.Separator, SeparatorReplacement);
|
||||
}
|
||||
|
||||
/// <summary>What <see cref="ToRawSegment"/> substitutes for a separator inside a browse name.</summary>
|
||||
private const char SeparatorReplacement = '_';
|
||||
|
||||
/// <summary>
|
||||
/// Names the browse leaves that <see cref="ToRawSegment"/> would land on one tag Name within one
|
||||
/// target group, or an empty list when the mapped rows are all distinct.
|
||||
/// </summary>
|
||||
/// <param name="browseNames">The ORIGINAL leaf names, in the same order as <paramref name="rows"/>.</param>
|
||||
/// <param name="rows">The mapped import rows.</param>
|
||||
/// <returns>One operator-facing message per colliding set; empty when there is nothing to refuse.</returns>
|
||||
/// <remarks>
|
||||
/// Sanitising maps many names onto one — <c>A/B</c> and <c>A_B</c> both become <c>A_B</c> — and a
|
||||
/// silent collision is materially worse than the refusal #509 replaced: two distinct metrics would
|
||||
/// become one tag bound to whichever address the import happened to keep. So the coercion is only
|
||||
/// safe when paired with this check.
|
||||
/// <para>
|
||||
/// Scoped per target group, because the Name only has to be unique within its group — the same
|
||||
/// metric name under two devices is not a collision.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The message names the specific pair rather than failing the batch anonymously. The commit
|
||||
/// itself stays all-or-nothing, matching the CSV import's staged-review semantics: nothing is
|
||||
/// written while any row is unresolvable.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static IReadOnlyList<string> DescribeNameCollisions(
|
||||
IReadOnlyList<string> browseNames,
|
||||
IReadOnlyList<RawTagImportRow> rows)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(browseNames);
|
||||
ArgumentNullException.ThrowIfNull(rows);
|
||||
|
||||
return rows
|
||||
.Select((row, i) => (row, original: i < browseNames.Count ? browseNames[i] : row.Tag.Name))
|
||||
.GroupBy(x => (x.row.TagGroupPath ?? "", x.row.Tag.Name), TupleComparer)
|
||||
.Where(g => g.Count() > 1)
|
||||
.Select(g =>
|
||||
$"'{g.Key.Item2}' would be the name of {g.Count()} different tags"
|
||||
+ (string.IsNullOrEmpty(g.Key.Item1) ? "" : $" in group '{g.Key.Item1}'")
|
||||
+ $": {string.Join(", ", g.Select(x => $"'{x.original}'"))}. "
|
||||
+ "Their names differ only by characters that are not legal in a tag name. Commit them "
|
||||
+ "separately, or author them by manual entry with distinct names.")
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>Ordinal comparer for the (group path, name) collision key — RawPath comparison is ordinal.</summary>
|
||||
private static readonly IEqualityComparer<(string, string)> TupleComparer =
|
||||
EqualityComparer<(string, string)>.Create(
|
||||
(a, b) => RawPaths.Comparer.Equals(a.Item1, b.Item1) && RawPaths.Comparer.Equals(a.Item2, b.Item2),
|
||||
x => HashCode.Combine(RawPaths.Comparer.GetHashCode(x.Item1), RawPaths.Comparer.GetHashCode(x.Item2)));
|
||||
|
||||
/// <summary>
|
||||
/// Maps a <see cref="DriverDataType"/> enum NAME (as reported by the browse attribute side-channel) to the
|
||||
/// OPC-UA built-in type name a <see cref="RawTagInput.DataType"/> carries. Mirrors
|
||||
|
||||
@@ -196,10 +196,12 @@ public sealed class MqttTagConfigModel
|
||||
/// <c>dataType</c> is the one key both modes write, from two different typed fields
|
||||
/// (<see cref="DataType"/> for Plain — always present; <see cref="MetricDataType"/> for
|
||||
/// Sparkplug — omitted unless the operator set an override): which field wins is decided by
|
||||
/// <see cref="Mode"/> at write time. Every other key is unconditional — mode-inapplicable keys
|
||||
/// (e.g. <c>topic</c> while Sparkplug, or <c>groupId</c> while Plain) are written/cleared exactly
|
||||
/// as their typed field says, which naturally preserves a retyped tag's other-mode leftovers
|
||||
/// untouched until the operator edits that field too.
|
||||
/// <see cref="Mode"/> at write time. <c>payloadFormat</c> is the one key written for <b>Plain
|
||||
/// only</b> (Gitea #513) — see its line below for why it alone needs the mode test. Every other
|
||||
/// key is unconditional: mode-inapplicable keys (e.g. <c>topic</c> while Sparkplug, or
|
||||
/// <c>groupId</c> while Plain) are written/cleared exactly as their typed field says, and because
|
||||
/// each of those fields is blank-⇒-omit, an absent key stays absent while a retyped tag's real
|
||||
/// other-mode leftovers survive untouched until the operator edits that field too.
|
||||
/// </remarks>
|
||||
/// <returns>The serialised TagConfig JSON string.</returns>
|
||||
public string ToJson()
|
||||
@@ -207,7 +209,14 @@ public sealed class MqttTagConfigModel
|
||||
// Blank ⇒ omit, like every other optional here: toggling the shape dropdown on an untouched tag
|
||||
// must not leave a stray "topic":"" behind.
|
||||
TagConfigJson.Set(_bag, TopicKey, string.IsNullOrWhiteSpace(Topic) ? null : Topic.Trim());
|
||||
TagConfigJson.Set(_bag, PayloadFormatKey, PayloadFormat);
|
||||
|
||||
// Plain-only (Gitea #513). Unlike every other Plain key here, PayloadFormat is a NON-NULLABLE
|
||||
// typed field, so an unconditional write does not merely preserve a retyped tag's leftover — it
|
||||
// INVENTS "payloadFormat":"Json" in the blob of a Sparkplug tag that never had one. The key names
|
||||
// a decode path that Sparkplug does not use (MqttTagDefinitionFactory routes on the descriptor
|
||||
// keys and never reads it), so it is a trap for anyone diagnosing a decode problem from the blob.
|
||||
TagConfigJson.Set(_bag, PayloadFormatKey, Mode == MqttMode.SparkplugB ? null : (object)PayloadFormat);
|
||||
|
||||
TagConfigJson.Set(_bag, JsonPathKey, string.IsNullOrWhiteSpace(JsonPath) ? null : JsonPath.Trim());
|
||||
TagConfigJson.Set(_bag, QosKey, Qos);
|
||||
TagConfigJson.Set(_bag, RetainSeedKey, RetainSeed);
|
||||
|
||||
Reference in New Issue
Block a user