namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing; /// /// Validates an operator-typed Sparkplug rebirth scope for the raw browse modal. /// /// /// /// Why a typed scope exists at all. The Sparkplug browse tree is birth-driven, 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. /// /// /// This is advisory, not the gate. MqttBrowseSession.ResolveRebirthTargets re-resolves /// the scope and refuses an unusable one before any message leaves, and the /// DriverOperator policy is enforced server-side in BrowserSessionService. 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. /// /// /// A group-only scope is rejected on purpose. The session resolves a bare group by enumerating /// the edge nodes it has observed — 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. /// /// public static class SparkplugRebirthScope { /// MQTT wildcards. A rebirth addresses one edge node exactly; a wildcard cannot name one. private static readonly char[] Wildcards = ['+', '#']; /// /// Describes why is not a usable {group}/{edgeNode} rebirth scope. /// /// The operator-typed scope, untrimmed; is treated as blank. /// The operator-facing error text, or when the scope is usable. 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; } /// True when is a usable rebirth scope. /// The operator-typed scope. /// when finds no fault. public static bool IsUsable(string? raw) => Describe(raw) is null; /// /// Splits a scope already accepted by into its two segments. /// /// A scope for which is . /// The trimmed (group, edgeNode) pair. /// The scope is not usable. 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()); } }