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
60 lines
3.2 KiB
C#
60 lines
3.2 KiB
C#
namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
|
|
|
/// <summary>One node in a driver-agnostic browse tree.</summary>
|
|
/// <param name="NodeId">Stable identifier passed back to the picker on commit. For OPC UA
|
|
/// this is the <c>nsu=...;...</c> form; for Galaxy this is the <c>tag_name</c>.</param>
|
|
/// <param name="DisplayName">Label shown in the tree.</param>
|
|
/// <param name="Kind">Whether this node terminates the address (Leaf) or has children
|
|
/// (Folder). Galaxy never returns Leaves; only the attribute side-panel terminates.</param>
|
|
/// <param name="HasChildrenHint">When true, the UI renders an expand affordance before
|
|
/// the children have been fetched.</param>
|
|
public sealed record BrowseNode(
|
|
string NodeId,
|
|
string DisplayName,
|
|
BrowseNodeKind Kind,
|
|
bool HasChildrenHint);
|
|
|
|
/// <summary>Discriminates terminal vs. expandable nodes for UI rendering.</summary>
|
|
public enum BrowseNodeKind
|
|
{
|
|
/// <summary>Expandable — has (or may have) children. UI shows expand affordance.</summary>
|
|
Folder,
|
|
/// <summary>Terminal — commit on select.</summary>
|
|
Leaf,
|
|
}
|
|
|
|
/// <summary>Metadata for an attribute of a Galaxy object (or the equivalent
|
|
/// per-driver concept). Surfaced in the picker's attribute side-panel.</summary>
|
|
/// <param name="IsAlarm">True when this attribute is itself an alarm condition (Galaxy: carries an
|
|
/// <c>AlarmExtension</c> primitive). The picker pre-fills a default native-alarm <c>alarm</c> object
|
|
/// into the TagConfig when an alarm attribute is selected. Defaults to false so non-alarm-aware
|
|
/// drivers (e.g. the OPC UA client browser) aren't forced to flow a flag they don't produce.</param>
|
|
/// <param name="AddressFields">
|
|
/// The <b>structured</b> address this leaf binds by, as <c>TagConfig</c> key → value in the
|
|
/// driver's own key vocabulary, for a driver whose address is a <i>tuple</i> rather than a single
|
|
/// reference string. Null (the default) for every driver whose address IS the
|
|
/// <see cref="BrowseNode.NodeId"/> — nothing changes for them.
|
|
/// <para>
|
|
/// It exists because a tuple cannot be recovered from an id string in general. MQTT/Sparkplug
|
|
/// is the case in point: a browse node id is
|
|
/// <c>{group}/{node}[/{device}]::{metric}</c>, and a metric name may itself contain <c>/</c>
|
|
/// (<c>Node Control/Rebirth</c>) — so the session keeps the decomposition it already has and
|
|
/// <b>states</b> it here, rather than the AdminUI's browse-commit mapper re-deriving it by
|
|
/// splitting the id and hoping. Same discipline as the v3 address space carrying
|
|
/// <c>AddressSpaceRealm</c> explicitly instead of parsing it out of a NodeId.
|
|
/// </para>
|
|
/// <para>
|
|
/// The producer states the binding <em>shape</em> too, by which keys it emits — so a consumer
|
|
/// never has to infer a driver's sub-mode from the tree's geometry. Consumers must read the
|
|
/// keys they know and ignore the rest; this is a description, not a config blob to splat into
|
|
/// a persisted TagConfig.
|
|
/// </para>
|
|
/// </param>
|
|
public sealed record AttributeInfo(
|
|
string Name,
|
|
string DriverDataType,
|
|
bool IsArray,
|
|
string SecurityClass,
|
|
bool IsAlarm = false,
|
|
IReadOnlyDictionary<string, string>? AddressFields = null);
|