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:
@@ -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 (0–2), 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
|
||||
|
||||
Reference in New Issue
Block a user