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:
+67
-10
@@ -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 (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
|
||||
|
||||
@@ -7,14 +7,15 @@ using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
// Typed AdminUI editor model for the Mqtt driver (P1 = Plain mode). The model is a thin typed view over
|
||||
// a preserved JsonObject key bag: every key the editor does not expose (history intent, array config,
|
||||
// alarm objects, and the Sparkplug keys Task 24 will add) must survive a load→save untouched.
|
||||
// Typed AdminUI editor model for the Mqtt driver — Plain (topic-bound) and Sparkplug B (birth/alias
|
||||
// ingest) modes. The model is a thin typed view over a preserved JsonObject key bag: every key the
|
||||
// editor does not expose (history intent, array config, alarm objects) must survive a load→save
|
||||
// untouched.
|
||||
//
|
||||
// The authoritative consumer of the produced blob is MqttTagDefinitionFactory.FromTagConfig
|
||||
// (Driver.Mqtt.Contracts) — the key names + strictness rules asserted here are that factory's, not this
|
||||
// editor's invention. TagConfig carries NO identity under v3 (the tag's RawPath is its identity), so
|
||||
// there is deliberately no FullName key.
|
||||
// The authoritative consumers of the produced blob are MqttTagDefinitionFactory.FromTagConfig (Plain)
|
||||
// and .FromSparkplugTagConfig (Sparkplug B) (Driver.Mqtt.Contracts) — the key names + strictness rules
|
||||
// asserted here are those factories', not this editor's invention. TagConfig carries NO identity under
|
||||
// v3 (the tag's RawPath is its identity), so there is deliberately no FullName key — in EITHER mode.
|
||||
public sealed class MqttTagConfigModelTests
|
||||
{
|
||||
[Theory]
|
||||
@@ -144,7 +145,7 @@ public sealed class MqttTagConfigModelTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromJson_then_ToJson_preserves_sparkplug_keys_task24_will_own()
|
||||
public void FromJson_then_ToJson_preserves_sparkplug_descriptor_keys()
|
||||
{
|
||||
var json = MqttTagConfigModel.FromJson(
|
||||
"""{"groupId":"Plant1","edgeNodeId":"E1","deviceId":"D1","metricName":"Temp"}""").ToJson();
|
||||
@@ -154,6 +155,146 @@ public sealed class MqttTagConfigModelTests
|
||||
o["edgeNodeId"]!.GetValue<string>().ShouldBe("E1");
|
||||
o["deviceId"]!.GetValue<string>().ShouldBe("D1");
|
||||
o["metricName"]!.GetValue<string>().ShouldBe("Temp");
|
||||
// TagConfig carries no identity under v3 even for a Sparkplug tag — see the corrections in the
|
||||
// Task 24 brief: FullName is a retired pre-v3 concept, not something this editor re-introduces.
|
||||
json.ShouldNotContain("FullName", Case.Sensitive);
|
||||
}
|
||||
|
||||
// ── Validate: Sparkplug rules (Task 24) ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Mirrors MqttTagDefinitionFactory.FromSparkplugTagConfig exactly: groupId/edgeNodeId/metricName
|
||||
// required, deviceId optional, dataType/qos read strictly but only when present. See the model's
|
||||
// Validate() remarks for the one place this editor is deliberately STRICTER than the factory
|
||||
// (illegal characters in the id segments) and why metricName is exempt from that rule.
|
||||
|
||||
[Fact]
|
||||
public void Validate_SparkplugMissingMetricName_Fails()
|
||||
=> MqttTagConfigModel.FromJson("""{"groupId":"Plant1","edgeNodeId":"EdgeA"}""")
|
||||
.Validate().ShouldNotBeNullOrWhiteSpace();
|
||||
|
||||
[Fact]
|
||||
public void Validate_SparkplugMissingGroupId_Fails()
|
||||
=> MqttTagConfigModel.FromJson("""{"edgeNodeId":"EdgeA","metricName":"Temperature"}""")
|
||||
.Validate().ShouldNotBeNullOrWhiteSpace();
|
||||
|
||||
[Fact]
|
||||
public void Validate_SparkplugMissingEdgeNodeId_Fails()
|
||||
=> MqttTagConfigModel.FromJson("""{"groupId":"Plant1","metricName":"Temperature"}""")
|
||||
.Validate().ShouldNotBeNullOrWhiteSpace();
|
||||
|
||||
[Fact]
|
||||
public void Validate_SparkplugValid_ReturnsNull()
|
||||
=> MqttTagConfigModel.FromJson(
|
||||
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"Temperature","dataType":"Float32"}""")
|
||||
.Validate().ShouldBeNull();
|
||||
|
||||
// deviceId is genuinely optional per the factory (absent ⇒ a node-level metric) — a Sparkplug tag
|
||||
// must validate cleanly without one.
|
||||
[Fact]
|
||||
public void Validate_SparkplugValidWithoutDeviceId_ReturnsNull()
|
||||
=> MqttTagConfigModel.FromJson(
|
||||
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature"}""")
|
||||
.Validate().ShouldBeNull();
|
||||
|
||||
// dataType is OPTIONAL in Sparkplug mode (the birth certificate declares it) — unlike Plain mode,
|
||||
// an absent dataType is not an error.
|
||||
[Fact]
|
||||
public void Validate_SparkplugAbsentDataType_ReturnsNull()
|
||||
=> MqttTagConfigModel.FromJson(
|
||||
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature"}""")
|
||||
.Validate().ShouldBeNull();
|
||||
|
||||
// ...but a PRESENT, typo'd dataType is still rejected — same strict-when-present rule as Plain,
|
||||
// and the same DriverDataType vocabulary (not the raw Sparkplug wire enum).
|
||||
[Fact]
|
||||
public void Validate_SparkplugTypoedDataType_Fails()
|
||||
=> MqttTagConfigModel.FromJson(
|
||||
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature","dataType":"Double"}""")
|
||||
.Validate().ShouldNotBeNullOrWhiteSpace();
|
||||
|
||||
[Theory]
|
||||
[InlineData("3")]
|
||||
[InlineData("-1")]
|
||||
public void Validate_SparkplugInvalidQos_Fails(string qosLiteral)
|
||||
=> MqttTagConfigModel.FromJson(
|
||||
$$"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature","qos":{{qosLiteral}}}""")
|
||||
.Validate().ShouldNotBeNullOrWhiteSpace();
|
||||
|
||||
// Editor-stricter-than-factory: group/edge-node/device ids are literal MQTT topic segments — a
|
||||
// decoded incoming id can never contain '/', '+', or '#', so an authored one that does could never
|
||||
// bind. Covers all three id fields plus both illegal characters.
|
||||
[Theory]
|
||||
[InlineData("groupId", "Plant1/A")]
|
||||
[InlineData("groupId", "Plant+1")]
|
||||
[InlineData("groupId", "Plant#1")]
|
||||
[InlineData("edgeNodeId", "Edge/A")]
|
||||
[InlineData("edgeNodeId", "Edge+A")]
|
||||
[InlineData("deviceId", "Dev/1")]
|
||||
[InlineData("deviceId", "Dev#1")]
|
||||
public void Validate_SparkplugIllegalCharacterInIdSegment_Fails(string field, string value)
|
||||
{
|
||||
var fields = new Dictionary<string, string>
|
||||
{
|
||||
["groupId"] = "Plant1",
|
||||
["edgeNodeId"] = "EdgeA",
|
||||
["deviceId"] = "Filler1",
|
||||
["metricName"] = "Temperature",
|
||||
};
|
||||
fields[field] = value;
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(fields);
|
||||
|
||||
MqttTagConfigModel.FromJson(json).Validate().ShouldNotBeNullOrWhiteSpace();
|
||||
}
|
||||
|
||||
// metricName is explicitly EXEMPT from the id-segment character rule — it is not a topic segment
|
||||
// (it comes from the payload, not the topic), and Sparkplug's own canonical examples use '/' in
|
||||
// metric names (e.g. "Node Control/Rebirth"). Over-rejecting here would block legitimate authoring.
|
||||
[Fact]
|
||||
public void Validate_SparkplugMetricNameWithSlash_IsAccepted()
|
||||
=> MqttTagConfigModel.FromJson(
|
||||
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Node Control/Rebirth"}""")
|
||||
.Validate().ShouldBeNull();
|
||||
|
||||
// ── ToJson: Sparkplug dataType override (Task 24) ───────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ToJson_SparkplugWithoutOverride_OmitsDataType()
|
||||
{
|
||||
var json = MqttTagConfigModel
|
||||
.FromJson("""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature"}""")
|
||||
.ToJson();
|
||||
|
||||
json.ShouldNotContain("dataType");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_Sparkplug_RoundTripsDescriptorFieldsAndDataTypeOverride()
|
||||
{
|
||||
var json = MqttTagConfigModel.FromJson(
|
||||
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"Temperature","dataType":"Float32"}""")
|
||||
.ToJson();
|
||||
|
||||
json.ShouldContain("\"groupId\":\"Plant1\"");
|
||||
json.ShouldContain("\"edgeNodeId\":\"EdgeA\"");
|
||||
json.ShouldContain("\"deviceId\":\"Filler1\"");
|
||||
json.ShouldContain("\"metricName\":\"Temperature\"");
|
||||
json.ShouldContain("\"dataType\":\"Float32\"");
|
||||
json.ShouldNotContain("FullName", Case.Sensitive);
|
||||
}
|
||||
|
||||
// A SparkplugB tag has NO 'mode' key to persist — it survives a save→reopen purely because the
|
||||
// descriptor keys it writes re-infer the mode on the next load.
|
||||
[Fact]
|
||||
public void FromJson_then_ToJson_then_FromJson_SparkplugMode_SurvivesReopen()
|
||||
{
|
||||
var m = MqttTagConfigModel.FromJson(
|
||||
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature"}""");
|
||||
m.Mode.ShouldBe(MqttMode.SparkplugB);
|
||||
|
||||
var json = m.ToJson();
|
||||
json.ShouldNotContain("\"mode\"");
|
||||
|
||||
MqttTagConfigModel.FromJson(json).Mode.ShouldBe(MqttMode.SparkplugB);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -270,8 +411,8 @@ public sealed class MqttTagConfigModelTests
|
||||
MqttTagConfigModel.FromJson(repaired).Validate().ShouldBeNull();
|
||||
}
|
||||
|
||||
// P1 stub: Sparkplug-shaped tags are not authored yet (Task 24 fills the rules). The Plain rules
|
||||
// must NOT be applied to them — a Sparkplug tag has no topic.
|
||||
// A well-formed Sparkplug tag is NOT subject to the Plain rules — it has no topic, and applying the
|
||||
// Plain "topic is required" check would reject every Sparkplug tag.
|
||||
[Fact]
|
||||
public void Validate_SparkplugMode_IsNotSubjectToPlainRules()
|
||||
=> MqttTagConfigModel.FromJson("""{"groupId":"P1","edgeNodeId":"E1","metricName":"Temp"}""")
|
||||
|
||||
Reference in New Issue
Block a user