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;
@@ -25,8 +25,10 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <see cref="Mode"/> is a <b>UI-only</b> sub-shape selector, inferred from the blob (the presence
/// of any Sparkplug descriptor key) and never serialised — the driver takes its
/// <c>Plain</c>/<c>SparkplugB</c> mode from the <em>driver</em> config, not from a tag, so
/// persisting a per-tag <c>mode</c> key would add a field the contract does not define. Only the
/// Plain rules are implemented in P1; the Sparkplug branch is a stub Task 24 fills.
/// persisting a per-tag <c>mode</c> key would add a field the contract does not define.
/// Consequently a SparkplugB tag survives a save→reopen purely because the descriptor keys it
/// writes (<see cref="GroupId"/>/<see cref="EdgeNodeId"/>/<see cref="DeviceId"/>/
/// <see cref="MetricName"/>) re-infer the mode on the next load — there is nothing else to persist.
/// </para>
/// </remarks>
public sealed class MqttTagConfigModel
@@ -37,6 +39,10 @@ public sealed class MqttTagConfigModel
private const string DataTypeKey = "dataType";
private const string QosKey = "qos";
private const string RetainSeedKey = "retainSeed";
private const string GroupIdKey = "groupId";
private const string EdgeNodeIdKey = "edgeNodeId";
private const string DeviceIdKey = "deviceId";
private const string MetricNameKey = "metricName";
/// <summary>
/// The JSONPath the driver applies when the blob omits <c>jsonPath</c> — the document root.
@@ -45,15 +51,29 @@ public sealed class MqttTagConfigModel
/// </summary>
private const string RootJsonPath = "$";
/// <summary>The Sparkplug B descriptor keys — authored by Task 24, preserved (never dropped) in P1.</summary>
private static readonly string[] SparkplugKeys = ["groupId", "edgeNodeId", "deviceId", "metricName"];
/// <summary>The Sparkplug B descriptor keys — any non-blank one marks a tag SparkplugB (see <see cref="InferMode"/>).</summary>
private static readonly string[] SparkplugKeys = [GroupIdKey, EdgeNodeIdKey, DeviceIdKey, MetricNameKey];
/// <summary>The MQTT wildcard characters; a tag's subscription topic must be concrete.</summary>
private static readonly char[] TopicWildcards = ['+', '#'];
/// <summary>
/// Characters illegal in a Sparkplug group/edge-node/device id. <c>/</c> is rejected because
/// these ids are literal MQTT topic segments — a decoded incoming id can never contain one
/// (the broker has already split the topic into segments before the driver sees it), so an
/// authored id containing <c>/</c> could never match a real birth: a permanently dead binding,
/// not an ambiguous one. <c>+</c>/<c>#</c> are rejected for the same "no legitimate
/// interpretation" reason the Plain topic wildcard check uses — here doubly so, since
/// <see cref="MqttDriverOptions.GroupId"/>'s only consumer builds the literal subscription
/// filter <c>spBv1.0/{GroupId}/#</c>, and embedding a wildcard character mid-segment is not
/// even legal there. <b>Not enforced by <c>MqttTagDefinitionFactory.FromSparkplugTagConfig</c></b>
/// — this is an editor-side rule stricter than the runtime parser, the same shape as the Plain
/// topic-wildcard rule below.
/// </summary>
private static readonly char[] SparkplugSegmentIllegalChars = ['/', '+', '#'];
/// <summary>
/// Which ingest shape this tag is authored under — inferred from the blob, never serialised.
/// P1 authors <see cref="MqttMode.Plain"/> only.
/// </summary>
public MqttMode Mode { get; set; } = MqttMode.Plain;
@@ -72,21 +92,56 @@ public sealed class MqttTagConfigModel
/// </summary>
public string JsonPath { get; set; } = "";
/// <summary>The tag's declared value type (the driver's <see cref="DriverDataType"/> set).</summary>
/// <summary>The tag's declared value type (Plain mode; always written). The driver's <see cref="DriverDataType"/> set.</summary>
public DriverDataType DataType { get; set; } = DriverDataType.String;
/// <summary>
/// Per-tag subscription QoS (02), or <c>null</c> to omit the key and inherit the driver-level
/// default. An absent key stays absent through a load→save.
/// default. An absent key stays absent through a load→save. Shared by both modes —
/// <c>MqttTagDefinitionFactory</c> reads <c>qos</c> identically for Plain and Sparkplug.
/// </summary>
public int? Qos { get; set; }
/// <summary>
/// Whether the broker's retained message seeds the tag's initial value, or <c>null</c> to omit
/// the key and inherit the driver's default (<c>true</c>). An absent key stays absent.
/// the key and inherit the driver's default (<c>true</c>). An absent key stays absent. Shared by
/// both modes — <c>MqttTagDefinitionFactory</c> reads <c>retainSeed</c> identically for Plain and
/// Sparkplug.
/// </summary>
public bool? RetainSeed { get; set; }
/// <summary>The Sparkplug group id (Sparkplug mode). Required; becomes part of the subscription filter.</summary>
public string GroupId { get; set; } = "";
/// <summary>The Sparkplug edge-node id (Sparkplug mode). Required.</summary>
public string EdgeNodeId { get; set; } = "";
/// <summary>
/// The Sparkplug device id (Sparkplug mode), or blank for a metric published by the edge node
/// itself rather than a device beneath it. Optional.
/// </summary>
public string DeviceId { get; set; } = "";
/// <summary>
/// The Sparkplug metric's stable name (Sparkplug mode). Required. Unlike
/// <see cref="GroupId"/>/<see cref="EdgeNodeId"/>/<see cref="DeviceId"/> this is NOT a topic
/// segment — it is read from the birth/data payload — so it is deliberately unrestricted and MAY
/// contain <c>/</c> (the canonical Sparkplug examples do: <c>Node Control/Rebirth</c>,
/// <c>Properties/Hardware Make</c>).
/// </summary>
public string MetricName { get; set; } = "";
/// <summary>
/// Sparkplug-mode data-type OVERRIDE (Sparkplug mode only), or <c>null</c> to omit the key and
/// let the tag take whatever type the metric's birth certificate declares — the whole point of
/// the driver's <c>UntilStable</c> discovery. Distinct from <see cref="DataType"/>, which is the
/// always-written Plain-mode field; both read/write the same <c>dataType</c> JSON key
/// (<c>MqttTagDefinitionFactory.FromSparkplugTagConfig</c> reads it as a <see cref="DriverDataType"/>
/// override, strictly, when present — the same enum Plain mode uses, not the raw wire
/// <c>SparkplugDataType</c>).
/// </summary>
public DriverDataType? MetricDataType { get; set; }
private JsonObject _bag = new();
/// <summary>
@@ -120,6 +175,13 @@ public sealed class MqttTagConfigModel
DataType = TagConfigJson.GetEnum(o, DataTypeKey, DriverDataType.String),
Qos = GetIntNullable(o, QosKey),
RetainSeed = TagConfigJson.GetBoolNullable(o, RetainSeedKey),
GroupId = TagConfigJson.GetString(o, GroupIdKey) ?? "",
EdgeNodeId = TagConfigJson.GetString(o, EdgeNodeIdKey) ?? "",
DeviceId = TagConfigJson.GetString(o, DeviceIdKey) ?? "",
MetricName = TagConfigJson.GetString(o, MetricNameKey) ?? "",
// Same "dataType" key as Plain's DataType above, read as an optional override — see the
// MetricDataType doc comment for why there are two typed fields over one JSON key.
MetricDataType = GetEnumNullable<DriverDataType>(o, DataTypeKey),
_bag = o,
};
}
@@ -130,6 +192,15 @@ public sealed class MqttTagConfigModel
/// rejected outright), and blank/null optionals are written as an absent key so the driver's own
/// defaults apply.
/// </summary>
/// <remarks>
/// <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.
/// </remarks>
/// <returns>The serialised TagConfig JSON string.</returns>
public string ToJson()
{
@@ -137,10 +208,18 @@ public sealed class MqttTagConfigModel
// must not leave a stray "topic":"" behind.
TagConfigJson.Set(_bag, TopicKey, string.IsNullOrWhiteSpace(Topic) ? null : Topic.Trim());
TagConfigJson.Set(_bag, PayloadFormatKey, PayloadFormat);
TagConfigJson.Set(_bag, DataTypeKey, DataType);
TagConfigJson.Set(_bag, JsonPathKey, string.IsNullOrWhiteSpace(JsonPath) ? null : JsonPath.Trim());
TagConfigJson.Set(_bag, QosKey, Qos);
TagConfigJson.Set(_bag, RetainSeedKey, RetainSeed);
// Mode-dependent: Plain always writes DataType; Sparkplug writes MetricDataType (null ⇒ omitted,
// "take the birth's declared type").
TagConfigJson.Set(_bag, DataTypeKey, Mode == MqttMode.SparkplugB ? MetricDataType : DataType);
TagConfigJson.Set(_bag, GroupIdKey, string.IsNullOrWhiteSpace(GroupId) ? null : GroupId.Trim());
TagConfigJson.Set(_bag, EdgeNodeIdKey, string.IsNullOrWhiteSpace(EdgeNodeId) ? null : EdgeNodeId.Trim());
TagConfigJson.Set(_bag, DeviceIdKey, string.IsNullOrWhiteSpace(DeviceId) ? null : DeviceId.Trim());
TagConfigJson.Set(_bag, MetricNameKey, string.IsNullOrWhiteSpace(MetricName) ? null : MetricName.Trim());
return TagConfigJson.Serialize(_bag);
}
@@ -169,13 +248,30 @@ public sealed class MqttTagConfigModel
/// blob that never went through this editor (e.g. a CSV import) cannot pass validation while the
/// driver would reject it.
/// </para>
/// <para>
/// <b>Sparkplug rules mirror <c>MqttTagDefinitionFactory.FromSparkplugTagConfig</c></b>:
/// <c>groupId</c>/<c>edgeNodeId</c>/<c>metricName</c> required (blank rejected exactly as the
/// factory hard-rejects them), <c>deviceId</c> optional, <c>dataType</c> and <c>qos</c> read
/// strictly but ONLY when present (matching <c>TryReadEnumStrict</c>'s absent/valid/invalid
/// split — a Sparkplug tag legitimately omits <c>dataType</c> and takes whatever the birth
/// certificate declares). Plain-only fields (<c>topic</c>, <c>payloadFormat</c>, <c>jsonPath</c>)
/// are NOT checked in this mode, matching the factory's "Plain-shape keys are read but not
/// required" stance for a retyped blob's leftovers.
/// </para>
/// <para>
/// ONE Sparkplug rule is likewise stricter than the factory: <c>groupId</c>/<c>edgeNodeId</c>/
/// <c>deviceId</c> reject <c>/</c>, <c>+</c>, and <c>#</c> — see
/// <see cref="SparkplugSegmentIllegalChars"/> for why (a decoded incoming id can never contain
/// one, so an authored id that does is a permanently dead binding, and <c>+</c>/<c>#</c> would
/// corrupt the driver's own subscription filter). <c>metricName</c> carries NO such restriction —
/// it is not a topic segment, and Sparkplug's own canonical metric names use <c>/</c> (e.g.
/// <c>Node Control/Rebirth</c>); rejecting it would block legitimate authoring.
/// </para>
/// </remarks>
/// <returns>An error message describing the validation failure, or <c>null</c> when valid.</returns>
public string? Validate()
{
// P1 stub: Sparkplug-shaped tags are not authored yet — Task 24 fills these rules. Applying the
// Plain rules to them would reject every Sparkplug tag for "missing topic".
if (Mode == MqttMode.SparkplugB) { return null; }
if (Mode == MqttMode.SparkplugB) { return ValidateSparkplug(); }
if (DescribeInvalidEnum<MqttPayloadFormat>(_bag, PayloadFormatKey) is { } pfError) { return pfError; }
if (DescribeInvalidEnum<DriverDataType>(_bag, DataTypeKey) is { } dtError) { return dtError; }
@@ -194,6 +290,45 @@ public sealed class MqttTagConfigModel
return null;
}
/// <summary>Sparkplug-mode half of <see cref="Validate"/> — see its remarks for the full rationale.</summary>
/// <returns>An error message describing the validation failure, or <c>null</c> when valid.</returns>
private string? ValidateSparkplug()
{
// Same DriverDataType enum + same strict absent/valid/invalid split as Plain's DataType check —
// dataType is one JSON key read by both FromTagConfig and FromSparkplugTagConfig identically.
if (DescribeInvalidEnum<DriverDataType>(_bag, DataTypeKey) is { } dtError) { return dtError; }
if (DescribeInvalidQos(_bag) is { } qosError) { return qosError; }
var groupId = GroupId.Trim();
var edgeNodeId = EdgeNodeId.Trim();
var deviceId = DeviceId.Trim();
var metricName = MetricName.Trim();
if (string.IsNullOrEmpty(groupId)) { return "A Sparkplug group ID is required."; }
if (string.IsNullOrEmpty(edgeNodeId)) { return "A Sparkplug edge node ID is required."; }
if (string.IsNullOrEmpty(metricName)) { return "A Sparkplug metric name is required."; }
if (DescribeInvalidSparkplugSegment("group ID", groupId) is { } gErr) { return gErr; }
if (DescribeInvalidSparkplugSegment("edge node ID", edgeNodeId) is { } eErr) { return eErr; }
// deviceId is optional — only validated when the operator actually supplied one.
if (deviceId.Length > 0 && DescribeInvalidSparkplugSegment("device ID", deviceId) is { } dErr) { return dErr; }
return null;
}
/// <summary>
/// Describes an <paramref name="value"/> that is illegal as a Sparkplug group/edge-node/device
/// id segment (see <see cref="SparkplugSegmentIllegalChars"/>), or <c>null</c> when clean.
/// </summary>
/// <param name="fieldLabel">The human-readable field name for the error message.</param>
/// <param name="value">The trimmed, non-blank field value to check.</param>
/// <returns>The error text, or <c>null</c>.</returns>
private static string? DescribeInvalidSparkplugSegment(string fieldLabel, string value)
=> value.IndexOfAny(SparkplugSegmentIllegalChars) >= 0
? $"Sparkplug {fieldLabel} '{value}' contains a character ('/', '+', or '#') that cannot appear " +
"in an MQTT topic segment; a decoded incoming id can never contain one, so this could never bind."
: null;
/// <summary>
/// Infers the editor's sub-shape from the blob: any non-blank Sparkplug descriptor key marks a
/// Sparkplug tag, otherwise Plain.
@@ -212,6 +347,18 @@ public sealed class MqttTagConfigModel
private static int? GetIntNullable(JsonObject o, string name)
=> o.TryGetPropertyValue(name, out var n) && n is JsonValue v && v.TryGetValue<int>(out var i) ? i : null;
/// <summary>
/// Reads an enum by its serialised name, or <c>null</c> when absent/unparseable — the nullable
/// counterpart of <see cref="TagConfigJson.GetEnum{TEnum}"/>, used for <see cref="MetricDataType"/>
/// so "the key is absent" (take the birth's declared type) is distinguishable from any real member.
/// </summary>
/// <typeparam name="TEnum">The enum type to parse.</typeparam>
/// <param name="o">The JSON object to read from.</param>
/// <param name="name">The property name to read.</param>
/// <returns>The parsed enum value, or <c>null</c>.</returns>
private static TEnum? GetEnumNullable<TEnum>(JsonObject o, string name) where TEnum : struct, Enum
=> TagConfigJson.GetString(o, name) is { } s && Enum.TryParse<TEnum>(s, ignoreCase: true, out var v) ? v : null;
/// <summary>
/// Describes a present-but-invalid enum field, or <c>null</c> when it is absent or valid.
/// Mirrors <c>TagConfigJson.TryReadEnum</c>'s absent/valid/invalid split exactly — including its