8d9155682d
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
101 lines
5.7 KiB
C#
101 lines
5.7 KiB
C#
namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
|
|
|
/// <summary>
|
|
/// A live, one-level-at-a-time browse over a remote address space. Owned by the
|
|
/// AdminUI <c>BrowseSessionRegistry</c>; disposed by the registry's TTL reaper or
|
|
/// the picker body on close.
|
|
/// </summary>
|
|
public interface IBrowseSession : IAsyncDisposable
|
|
{
|
|
/// <summary>Opaque token identifying this session in the registry.</summary>
|
|
Guid Token { get; }
|
|
|
|
/// <summary>Wall-clock time of the most recent successful call. Refreshed on
|
|
/// <see cref="RootAsync"/>, <see cref="ExpandAsync"/>, and
|
|
/// <see cref="AttributesAsync"/>; used by the reaper for idle eviction.</summary>
|
|
DateTime LastUsedUtc { get; }
|
|
|
|
/// <summary>Returns the top-level browse nodes.</summary>
|
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
|
/// <returns>The top-level browse nodes.</returns>
|
|
Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken cancellationToken);
|
|
|
|
/// <summary>Returns the direct children of the node identified by
|
|
/// <paramref name="nodeId"/>.</summary>
|
|
/// <param name="nodeId">The node whose direct children are returned.</param>
|
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
|
/// <returns>The direct children of the node.</returns>
|
|
Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken cancellationToken);
|
|
|
|
/// <summary>Returns the attributes of the node identified by <paramref name="nodeId"/>.
|
|
/// Empty for drivers whose tree is uniform (OPC UA Client). Galaxy uses this to populate
|
|
/// the attribute side-panel after the user selects an object.</summary>
|
|
/// <param name="nodeId">The node whose attributes are returned.</param>
|
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
|
/// <returns>The attributes of the node.</returns>
|
|
Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// An <see cref="IBrowseSession"/> whose protocol offers an explicit, operator-triggered
|
|
/// <b>re-announce</b> action: a request that the remote peer republish its self-description so the
|
|
/// observation window can fill without waiting for the next natural announcement. Implemented
|
|
/// today only by the MQTT/Sparkplug browser, whose tree is built from observed NBIRTH/DBIRTH
|
|
/// certificates.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>This is the only interface in the browse contract that causes an outbound message.</b>
|
|
/// Every other browse member is strictly read-only, and for the MQTT browser that read-only
|
|
/// property is load-bearing: a picker opened by an operator runs against a live production
|
|
/// broker. It is a separate interface, rather than an optional member on
|
|
/// <see cref="IBrowseSession"/>, precisely so "does this session write?" stays a type
|
|
/// question a caller cannot forget to ask.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Callers must authorize before invoking it.</b> The AdminUI routes it through
|
|
/// <c>BrowserSessionService.RequestRebirthAsync</c>, which enforces the same
|
|
/// <c>DriverOperator</c> policy that gates the picker's Browse affordance — an implementation
|
|
/// cannot check that itself, since it holds no user identity.
|
|
/// </para>
|
|
/// </remarks>
|
|
public interface IRebirthCapableBrowseSession : IBrowseSession
|
|
{
|
|
/// <summary>
|
|
/// Whether this <i>particular</i> session can actually re-announce — the runtime half of the
|
|
/// type question, and the one a UI must ask before offering the affordance.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// One session class may serve several protocol shapes: the MQTT browser opens the same session
|
|
/// type for Plain and for Sparkplug B, and a Plain MQTT window publishes <b>nothing, ever</b> —
|
|
/// there is no plain-MQTT re-announce to offer. Implementing the interface therefore means "this
|
|
/// session type may re-announce"; this property means "this instance will". A UI gating on the
|
|
/// type alone would draw a button that can only ever throw.
|
|
/// <para>
|
|
/// This is <b>not</b> an authorization signal — see the interface remarks. False here hides
|
|
/// the affordance; the caller still authorizes before invoking
|
|
/// <see cref="RequestRebirthAsync"/>, and the implementation still refuses a call it cannot
|
|
/// serve.
|
|
/// </para>
|
|
/// </remarks>
|
|
bool RebirthAvailable { get; }
|
|
|
|
/// <summary>
|
|
/// Asks the addressed remote peer(s) to re-announce themselves.
|
|
/// </summary>
|
|
/// <param name="scope">
|
|
/// The protocol-specific target. For Sparkplug: a browse <c>NodeId</c> from this session's own
|
|
/// tree (group, edge node, device or metric — resolved up to the owning edge node), or a bare
|
|
/// <c>{group}/{edgeNode}</c> pair for a node that has not been observed yet.
|
|
/// </param>
|
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
|
/// <returns>The number of request messages published.</returns>
|
|
/// <exception cref="ArgumentException"><paramref name="scope"/> is empty or unusable.</exception>
|
|
/// <exception cref="InvalidOperationException">
|
|
/// The scope resolves to no target, or to more targets than the implementation will fan out to
|
|
/// in one action. Nothing is published in either case.
|
|
/// </exception>
|
|
/// <exception cref="NotSupportedException">This session's mode has no re-announce action.</exception>
|
|
Task<int> RequestRebirthAsync(string scope, CancellationToken cancellationToken);
|
|
}
|