feat(mqtt): typed tag editor Sparkplug mode + validation

Task 24 (P2): replaces the P1 Sparkplug Validate() stub (always null) and the
"not available yet" editor placeholder with real groupId/edgeNodeId/deviceId/
metricName authoring, mirroring MqttTagDefinitionFactory.FromSparkplugTagConfig
in both directions -- required ids + optional deviceId, dataType/qos read
strictly but only when present (dataType is optional: the birth certificate
declares it). One rule is deliberately stricter than the factory: group/edge-
node/device ids reject '/', '+', '#' (topic-segment characters a decoded
incoming id can never carry, so an authored one that does can never bind);
metricName is exempt since Sparkplug's own canonical names use '/' (e.g.
"Node Control/Rebirth"). No FullName key is written -- TagConfig carries no
identity under v3, and the plan's snippet asking for one was corrected per the
brief. A SparkplugB tag now survives save/reopen because its descriptor keys
re-infer Mode on load; there was never a 'mode' key to persist.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 22:43:44 -04:00
parent 6917d8805d
commit cd0157a3b8
3 changed files with 377 additions and 32 deletions
@@ -3,10 +3,11 @@
dispatch uniformity and unused here (MQTT has no address-builder picker — topics are discovered
through the /raw browse tree, not composed from parts).
P1 authors Plain-mode tags only. The shape switch is real (it drives which field group renders and
what Validate() applies) but it is a UI-only choice derived from the blob, never serialised — the
driver takes Plain vs SparkplugB from its DRIVER config, and the tag blob has no 'mode' key.
Task 24 replaces the Sparkplug placeholder below with the real descriptor field group. *@
The shape switch is real (it drives which field group renders and what Validate() applies) but it
is a UI-only choice derived from the blob, never serialised — the driver takes Plain vs SparkplugB
from its DRIVER config, and the tag blob has no 'mode' key. A SparkplugB tag survives a save→reopen
because its descriptor keys (groupId/edgeNodeId/deviceId/metricName) re-infer the mode on load —
there is nothing else that needs to persist. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
@using ZB.MOM.WW.OtOpcUa.Driver.Mqtt
@@ -78,12 +79,64 @@
}
else
{
<div class="col-12">
<div class="alert alert-info py-2 px-3 mb-0 small" role="alert">
Sparkplug B tag authoring is not available yet — the group / edge-node / device /
metric fields land with Sparkplug ingest. Switch back to <strong>Plain</strong> to author
a topic-bound tag. Any existing Sparkplug keys on this tag are preserved untouched.
</div>
<div class="col-md-4">
<label class="form-label" for="mqtt-sp-group">Group ID</label>
<input id="mqtt-sp-group" type="text" class="form-control form-control-sm mono"
placeholder="Plant1" value="@_m.GroupId"
@onchange="@(e => Update(() => _m.GroupId = e.Value?.ToString() ?? ""))" />
<div class="form-text">The driver subscribes <code>spBv1.0/@(string.IsNullOrEmpty(_m.GroupId) ? "{GroupId}" : _m.GroupId)/#</code>. No <code>/</code>, <code>+</code>, or <code>#</code>.</div>
</div>
<div class="col-md-4">
<label class="form-label" for="mqtt-sp-node">Edge node ID</label>
<input id="mqtt-sp-node" type="text" class="form-control form-control-sm mono"
placeholder="EdgeA" value="@_m.EdgeNodeId"
@onchange="@(e => Update(() => _m.EdgeNodeId = e.Value?.ToString() ?? ""))" />
<div class="form-text">The publishing edge node's id.</div>
</div>
<div class="col-md-4">
<label class="form-label" for="mqtt-sp-device">Device ID <span class="text-muted">(optional)</span></label>
<input id="mqtt-sp-device" type="text" class="form-control form-control-sm mono"
placeholder="(node-level metric)" value="@_m.DeviceId"
@onchange="@(e => Update(() => _m.DeviceId = e.Value?.ToString() ?? ""))" />
<div class="form-text">Leave blank for a metric published by the edge node itself.</div>
</div>
<div class="col-md-6">
<label class="form-label" for="mqtt-sp-metric">Metric name</label>
<input id="mqtt-sp-metric" type="text" class="form-control form-control-sm mono"
placeholder="Node Control/Rebirth" value="@_m.MetricName"
@onchange="@(e => Update(() => _m.MetricName = e.Value?.ToString() ?? ""))" />
<div class="form-text">The metric's stable name from the birth certificate — unlike the ids above, this MAY contain <code>/</code> (e.g. <code>Properties/Hardware Make</code>).</div>
</div>
<div class="col-md-3">
<label class="form-label" for="mqtt-sp-data-type">Data type override</label>
@* Same DriverDataType set as Plain's field — the factory reads a Sparkplug 'dataType' key as
a DriverDataType override, not the raw wire SparkplugDataType. Absent ⇒ take whatever type
the birth certificate declares (the driver's UntilStable discovery). *@
<select id="mqtt-sp-data-type" class="form-select form-select-sm" value="@(_m.MetricDataType?.ToString() ?? "")"
@onchange="@(e => Update(() => _m.MetricDataType = ParseNullableEnum<DriverDataType>(e.Value)))">
<option value="">(from birth certificate)</option>
@foreach (var v in Enum.GetValues<DriverDataType>()) { <option value="@v">@v</option> }
</select>
</div>
<div class="col-md-3">
<label class="form-label" for="mqtt-sp-qos">QoS</label>
<select id="mqtt-sp-qos" class="form-select form-select-sm" value="@(_m.Qos?.ToString() ?? "")"
@onchange="@(e => Update(() => _m.Qos = ParseNullableInt(e.Value)))">
<option value="">(driver default)</option>
<option value="0">0 — at most once</option>
<option value="1">1 — at least once</option>
<option value="2">2 — exactly once</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label" for="mqtt-sp-retain-seed">Retained-message seed</label>
<select id="mqtt-sp-retain-seed" class="form-select form-select-sm"
value="@(_m.RetainSeed is null ? "" : _m.RetainSeed.Value ? "true" : "false")"
@onchange="@(e => Update(() => _m.RetainSeed = ParseNullableBool(e.Value)))">
<option value="">(driver default)</option>
<option value="true">Seed from retained message</option>
<option value="false">Wait for a live publish</option>
</select>
</div>
}
@@ -121,6 +174,10 @@
private static TEnum ParseEnum<TEnum>(object? v, TEnum fallback) where TEnum : struct, Enum
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : fallback;
// "" (the "(from birth certificate)" sentinel option) ⇒ null ⇒ the key is omitted.
private static TEnum? ParseNullableEnum<TEnum>(object? v) where TEnum : struct, Enum
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : null;
// "" ⇒ null ⇒ the key is omitted and the driver's own default applies.
private static int? ParseNullableInt(object? v)
=> int.TryParse(v?.ToString(), out var i) ? i : null;