feat(mqtt): typed tag editor + validator (plain mode)
Thin typed model over a preserved JsonObject key bag, mirroring the sibling <Driver>TagConfigModel template. Key names and strictness rules mirror MqttTagDefinitionFactory rather than inventing an editor-side schema; enums round-trip as NAMES (the factory reads them strictly by name). No FullName key: under v3 a tag is identified by its RawPath, which the factory keys the definition's Name off — a composed identity key here would be dead weight nothing reads. Mode is a UI-only sub-shape selector inferred from the blob (any Sparkplug descriptor key present) and never serialised — the driver takes Plain vs SparkplugB from its DRIVER config. Only Plain Validate() is implemented; the Sparkplug branch is a stub Task 24 fills, and its keys are preserved untouched. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
+141
@@ -0,0 +1,141 @@
|
|||||||
|
@* Typed TagConfig editor for the Mqtt driver. Same (ConfigJson/ConfigJsonChanged/DriverType/
|
||||||
|
GetDriverConfigJson) parameter shape every typed editor takes; the last two are accepted for
|
||||||
|
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. *@
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Driver.Mqtt
|
||||||
|
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label" for="mqtt-mode">Tag shape</label>
|
||||||
|
<select id="mqtt-mode" class="form-select form-select-sm" value="@_m.Mode"
|
||||||
|
@onchange="@(e => Update(() => _m.Mode = ParseEnum(e.Value, MqttMode.Plain)))">
|
||||||
|
@foreach (var v in Enum.GetValues<MqttMode>()) { <option value="@v">@v</option> }
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_m.Mode == MqttMode.Plain)
|
||||||
|
{
|
||||||
|
<div class="col-md-8">
|
||||||
|
<label class="form-label" for="mqtt-topic">Topic</label>
|
||||||
|
<input id="mqtt-topic" type="text" class="form-control form-control-sm mono"
|
||||||
|
placeholder="factory/oven/temp" value="@_m.Topic"
|
||||||
|
@onchange="@(e => Update(() => _m.Topic = e.Value?.ToString() ?? ""))" />
|
||||||
|
<div class="form-text">Concrete topic — MQTT wildcards (+ / #) are not valid for a tag.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label" for="mqtt-payload-format">Payload format</label>
|
||||||
|
<select id="mqtt-payload-format" class="form-select form-select-sm" value="@_m.PayloadFormat"
|
||||||
|
@onchange="@(e => Update(() => _m.PayloadFormat = ParseEnum(e.Value, MqttPayloadFormat.Json)))">
|
||||||
|
@foreach (var v in Enum.GetValues<MqttPayloadFormat>()) { <option value="@v">@v</option> }
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
@if (_m.PayloadFormat == MqttPayloadFormat.Json)
|
||||||
|
{
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label" for="mqtt-json-path">JSON path</label>
|
||||||
|
<input id="mqtt-json-path" type="text" class="form-control form-control-sm mono"
|
||||||
|
placeholder="$.value" value="@_m.JsonPath"
|
||||||
|
@onchange="@(e => Update(() => _m.JsonPath = e.Value?.ToString() ?? ""))" />
|
||||||
|
<div class="form-text">Use <code>$</code> for the whole document.</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label" for="mqtt-data-type">Data type</label>
|
||||||
|
@* The driver's own DriverDataType members — authoring anything outside this set produces a
|
||||||
|
blob the driver's strict parser rejects at deploy (there is no 'Double'; it is Float64). *@
|
||||||
|
<select id="mqtt-data-type" class="form-select form-select-sm" value="@_m.DataType"
|
||||||
|
@onchange="@(e => Update(() => _m.DataType = ParseEnum(e.Value, DriverDataType.String)))">
|
||||||
|
@foreach (var v in Enum.GetValues<DriverDataType>()) { <option value="@v">@v</option> }
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label" for="mqtt-qos">QoS</label>
|
||||||
|
<select id="mqtt-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-4">
|
||||||
|
<label class="form-label" for="mqtt-retain-seed">Retained-message seed</label>
|
||||||
|
<select id="mqtt-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>
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_validationError is not null)
|
||||||
|
{
|
||||||
|
<div class="col-12"><div class="text-danger small">@_validationError</div></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>The tag's TagConfig JSON, owned by the host modal.</summary>
|
||||||
|
[Parameter] public string? ConfigJson { get; set; }
|
||||||
|
/// <summary>Raised with the re-serialised TagConfig JSON after every field edit.</summary>
|
||||||
|
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
|
||||||
|
/// <summary>DriverType of the owning driver — accepted for dispatch uniformity; unused here.</summary>
|
||||||
|
[Parameter] public string DriverType { get; set; } = "";
|
||||||
|
/// <summary>Live accessor for the owning driver's DriverConfig JSON — accepted for dispatch uniformity; unused here.</summary>
|
||||||
|
[Parameter] public Func<string> GetDriverConfigJson { get; set; } = () => "{}";
|
||||||
|
|
||||||
|
private MqttTagConfigModel _m = new();
|
||||||
|
private string? _lastConfigJson;
|
||||||
|
private string? _validationError;
|
||||||
|
|
||||||
|
// Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render
|
||||||
|
// (Blazor Server live-status pushes do this) can't reset the user's in-progress edits.
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
if (ConfigJson == _lastConfigJson) { return; }
|
||||||
|
_lastConfigJson = ConfigJson;
|
||||||
|
_m = MqttTagConfigModel.FromJson(ConfigJson);
|
||||||
|
_validationError = _m.Validate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// TryParse so a bad/empty change value can never throw into the Blazor circuit — it falls back.
|
||||||
|
private static TEnum ParseEnum<TEnum>(object? v, TEnum fallback) where TEnum : struct, Enum
|
||||||
|
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : fallback;
|
||||||
|
|
||||||
|
// "" ⇒ 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;
|
||||||
|
|
||||||
|
private static bool? ParseNullableBool(object? v)
|
||||||
|
=> bool.TryParse(v?.ToString(), out var b) ? b : null;
|
||||||
|
|
||||||
|
private async Task Update(Action apply)
|
||||||
|
{
|
||||||
|
apply();
|
||||||
|
_validationError = _m.Validate();
|
||||||
|
var json = _m.ToJson();
|
||||||
|
// Keep the guard in OnParametersSet in step with what we just emitted, so the host echoing the
|
||||||
|
// new JSON back as a parameter cannot re-parse and clobber an in-progress edit.
|
||||||
|
_lastConfigJson = json;
|
||||||
|
await ConfigJsonChanged.InvokeAsync(json);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Typed working model for an MQTT tag's TagConfig JSON — the driver-specific binding fields
|
||||||
|
/// (name / access level / writability live on the Tag entity). Preserves unrecognised JSON keys
|
||||||
|
/// across a load→save.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The authoritative consumer of the produced blob is
|
||||||
|
/// <c>MqttTagDefinitionFactory.FromTagConfig</c> (<c>Driver.Mqtt.Contracts</c>); every key name and
|
||||||
|
/// strictness rule here mirrors that factory rather than inventing an editor-side schema.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>There is deliberately no <c>FullName</c> (or any other identity) key.</b> Under the v3
|
||||||
|
/// identity contract a tag is identified by its <b>RawPath</b> — the factory keys the produced
|
||||||
|
/// definition's <c>Name</c> off the RawPath it is handed, and the TagConfig is a pure address blob.
|
||||||
|
/// Writing a composed identity key here would be dead weight nothing reads.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class MqttTagConfigModel
|
||||||
|
{
|
||||||
|
private const string TopicKey = "topic";
|
||||||
|
private const string PayloadFormatKey = "payloadFormat";
|
||||||
|
private const string JsonPathKey = "jsonPath";
|
||||||
|
private const string DataTypeKey = "dataType";
|
||||||
|
private const string QosKey = "qos";
|
||||||
|
private const string RetainSeedKey = "retainSeed";
|
||||||
|
|
||||||
|
/// <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 MQTT wildcard characters; a tag's subscription topic must be concrete.</summary>
|
||||||
|
private static readonly char[] TopicWildcards = ['+', '#'];
|
||||||
|
|
||||||
|
/// <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;
|
||||||
|
|
||||||
|
/// <summary>The concrete MQTT topic the tag subscribes to (Plain mode). Required; no wildcards.</summary>
|
||||||
|
public string Topic { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>How the received payload is decoded (Plain mode).</summary>
|
||||||
|
public MqttPayloadFormat PayloadFormat { get; set; } = MqttPayloadFormat.Json;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// JSONPath selecting the value inside a <see cref="MqttPayloadFormat.Json"/> payload. Blank ⇒
|
||||||
|
/// the key is omitted and the driver applies the document root (<c>$</c>).
|
||||||
|
/// </summary>
|
||||||
|
public string JsonPath { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>The tag's declared value type (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.
|
||||||
|
/// </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.
|
||||||
|
/// </summary>
|
||||||
|
public bool? RetainSeed { get; set; }
|
||||||
|
|
||||||
|
private JsonObject _bag = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads a model from a TagConfig JSON string, defaulting any absent field and retaining every
|
||||||
|
/// original key (so fields this editor doesn't expose — history intent, array/alarm objects, and
|
||||||
|
/// the Sparkplug descriptor keys — survive a load→save).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="json">The raw TagConfig JSON string, or <c>null</c> for a new/empty config.</param>
|
||||||
|
/// <returns>The populated <see cref="MqttTagConfigModel"/>.</returns>
|
||||||
|
public static MqttTagConfigModel FromJson(string? json)
|
||||||
|
{
|
||||||
|
var o = TagConfigJson.ParseOrNew(json);
|
||||||
|
return new MqttTagConfigModel
|
||||||
|
{
|
||||||
|
Mode = InferMode(o),
|
||||||
|
Topic = TagConfigJson.GetString(o, TopicKey) ?? "",
|
||||||
|
PayloadFormat = TagConfigJson.GetEnum(o, PayloadFormatKey, MqttPayloadFormat.Json),
|
||||||
|
JsonPath = TagConfigJson.GetString(o, JsonPathKey) ?? "",
|
||||||
|
DataType = TagConfigJson.GetEnum(o, DataTypeKey, DriverDataType.String),
|
||||||
|
Qos = GetIntNullable(o, QosKey),
|
||||||
|
RetainSeed = TagConfigJson.GetBoolNullable(o, RetainSeedKey),
|
||||||
|
_bag = o,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serialises this model back to a TagConfig JSON string over the preserved key bag. Enums are
|
||||||
|
/// written as their <b>names</b> (the driver reads them strictly by name; an ordinal would be
|
||||||
|
/// rejected outright), and blank/null optionals are written as an absent key so the driver's own
|
||||||
|
/// defaults apply.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The serialised TagConfig JSON string.</returns>
|
||||||
|
public string ToJson()
|
||||||
|
{
|
||||||
|
TagConfigJson.Set(_bag, TopicKey, 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);
|
||||||
|
return TagConfigJson.Serialize(_bag);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Client-side validation run by <see cref="TagConfigValidator"/> before a tag is saved.
|
||||||
|
/// Returns the first error, or <c>null</c> when the config is valid.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Two rules are deliberately <b>stricter</b> than the runtime parser, because this is an
|
||||||
|
/// authoring surface and the cheapest place to stop the mistake: a wildcard topic (the runtime
|
||||||
|
/// accepts it and <c>Inspect</c> only warns at deploy) and a <c>Json</c> payload with no
|
||||||
|
/// <c>jsonPath</c> (the runtime defaults to the document root). Everything else — required
|
||||||
|
/// topic, strict enums, QoS 0–2 — matches <c>MqttTagDefinitionFactory</c> exactly. The strict
|
||||||
|
/// enum/QoS checks read the ORIGINAL key bag, not the defaulted typed fields, so a blob that
|
||||||
|
/// never went through this editor (e.g. a CSV import) cannot pass validation while the driver
|
||||||
|
/// would reject it.
|
||||||
|
/// </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 (DescribeInvalidEnum<MqttPayloadFormat>(_bag, PayloadFormatKey) is { } pfError) { return pfError; }
|
||||||
|
if (DescribeInvalidEnum<DriverDataType>(_bag, DataTypeKey) is { } dtError) { return dtError; }
|
||||||
|
if (DescribeInvalidQos(_bag) is { } qosError) { return qosError; }
|
||||||
|
|
||||||
|
var topic = Topic.Trim();
|
||||||
|
if (string.IsNullOrEmpty(topic)) { return "A topic is required."; }
|
||||||
|
if (topic.IndexOfAny(TopicWildcards) >= 0)
|
||||||
|
{
|
||||||
|
return $"Topic '{topic}' contains an MQTT wildcard (+ or #); a tag's topic must be concrete, "
|
||||||
|
+ "or the tag would be fed by every matching topic.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PayloadFormat == MqttPayloadFormat.Json && string.IsNullOrWhiteSpace(JsonPath))
|
||||||
|
{
|
||||||
|
return "A jsonPath is required when the payload format is Json (use \"$\" for the whole document).";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Infers the editor's sub-shape from the blob: any non-blank Sparkplug descriptor key marks a
|
||||||
|
/// Sparkplug tag, otherwise Plain.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="o">The parsed TagConfig key bag.</param>
|
||||||
|
/// <returns>The inferred mode.</returns>
|
||||||
|
private static MqttMode InferMode(JsonObject o)
|
||||||
|
=> SparkplugKeys.Any(k => !string.IsNullOrWhiteSpace(TagConfigJson.GetString(o, k)))
|
||||||
|
? MqttMode.SparkplugB
|
||||||
|
: MqttMode.Plain;
|
||||||
|
|
||||||
|
/// <summary>Reads an int value, or <c>null</c> when absent/null/not an integer (so an absent key stays absent).</summary>
|
||||||
|
/// <param name="o">The JSON object to read from.</param>
|
||||||
|
/// <param name="name">The property name to read.</param>
|
||||||
|
/// <returns>The int value, or <c>null</c>.</returns>
|
||||||
|
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>
|
||||||
|
/// 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
|
||||||
|
/// treatment of a present-but-non-string value as ABSENT — so the editor never blocks a blob the
|
||||||
|
/// driver would happily default.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TEnum">The enum type expected.</typeparam>
|
||||||
|
/// <param name="o">The TagConfig key bag.</param>
|
||||||
|
/// <param name="name">The property name to describe.</param>
|
||||||
|
/// <returns>The error text, or <c>null</c>.</returns>
|
||||||
|
private static string? DescribeInvalidEnum<TEnum>(JsonObject o, string name) where TEnum : struct, Enum
|
||||||
|
{
|
||||||
|
if (!o.TryGetPropertyValue(name, out var n) || n is not JsonValue v || !v.TryGetValue<string>(out var raw))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Enum.TryParse<TEnum>(raw, ignoreCase: true, out _)
|
||||||
|
? null
|
||||||
|
: $"'{raw}' is not a valid {name}; valid: {string.Join(", ", Enum.GetNames<TEnum>())}.";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Describes a present-but-invalid <c>qos</c> field, or <c>null</c> when it is absent or a legal
|
||||||
|
/// MQTT QoS. Matches the factory's strict read: absent ⇒ fine; a JSON integer 0–2 ⇒ fine;
|
||||||
|
/// anything else present (non-number, non-integer, out of range) ⇒ rejected.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="o">The TagConfig key bag.</param>
|
||||||
|
/// <returns>The error text, or <c>null</c>.</returns>
|
||||||
|
private static string? DescribeInvalidQos(JsonObject o)
|
||||||
|
{
|
||||||
|
if (!o.TryGetPropertyValue(QosKey, out var n) || n is null) { return null; }
|
||||||
|
if (n is JsonValue v && v.TryGetValue<int>(out var i) && i is >= 0 and <= 2) { return null; }
|
||||||
|
return $"'{n.ToJsonString()}' is not a valid QoS; valid: 0, 1, 2.";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ public static class TagConfigEditorMap
|
|||||||
[DriverTypeNames.FOCAS] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor),
|
[DriverTypeNames.FOCAS] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor),
|
||||||
[DriverTypeNames.OpcUaClient] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor),
|
[DriverTypeNames.OpcUaClient] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor),
|
||||||
[DriverTypeNames.Calculation] = typeof(Components.Shared.Uns.TagEditors.CalculationTagConfigEditor),
|
[DriverTypeNames.Calculation] = typeof(Components.Shared.Uns.TagEditors.CalculationTagConfigEditor),
|
||||||
|
[DriverTypeNames.Mqtt] = typeof(Components.Shared.Uns.TagEditors.MqttTagConfigEditor),
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Returns the editor component type for a driver type, or null if none is registered.</summary>
|
/// <summary>Returns the editor component type for a driver type, or null if none is registered.</summary>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ public static class TagConfigValidator
|
|||||||
[DriverTypeNames.FOCAS] = j => FocasTagConfigModel.FromJson(j).Validate(),
|
[DriverTypeNames.FOCAS] = j => FocasTagConfigModel.FromJson(j).Validate(),
|
||||||
[DriverTypeNames.OpcUaClient] = j => OpcUaClientTagConfigModel.FromJson(j).Validate(),
|
[DriverTypeNames.OpcUaClient] = j => OpcUaClientTagConfigModel.FromJson(j).Validate(),
|
||||||
[DriverTypeNames.Calculation] = j => CalculationTagConfigModel.FromJson(j).Validate(),
|
[DriverTypeNames.Calculation] = j => CalculationTagConfigModel.FromJson(j).Validate(),
|
||||||
|
[DriverTypeNames.Mqtt] = j => MqttTagConfigModel.FromJson(j).Validate(),
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
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.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
public sealed class MqttTagConfigModelTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
[InlineData("{}")]
|
||||||
|
[InlineData("not json at all")]
|
||||||
|
public void FromJson_returns_defaults_for_empty_input(string? json)
|
||||||
|
{
|
||||||
|
var m = MqttTagConfigModel.FromJson(json);
|
||||||
|
|
||||||
|
m.Mode.ShouldBe(MqttMode.Plain);
|
||||||
|
m.Topic.ShouldBe("");
|
||||||
|
m.PayloadFormat.ShouldBe(MqttPayloadFormat.Json);
|
||||||
|
m.JsonPath.ShouldBe("");
|
||||||
|
m.DataType.ShouldBe(DriverDataType.String);
|
||||||
|
m.Qos.ShouldBeNull();
|
||||||
|
m.RetainSeed.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromJson_reads_every_plain_field()
|
||||||
|
{
|
||||||
|
var m = MqttTagConfigModel.FromJson(
|
||||||
|
"""{"topic":"factory/oven/temp","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64","qos":1,"retainSeed":false}""");
|
||||||
|
|
||||||
|
m.Mode.ShouldBe(MqttMode.Plain);
|
||||||
|
m.Topic.ShouldBe("factory/oven/temp");
|
||||||
|
m.PayloadFormat.ShouldBe(MqttPayloadFormat.Json);
|
||||||
|
m.JsonPath.ShouldBe("$.value");
|
||||||
|
m.DataType.ShouldBe(DriverDataType.Float64);
|
||||||
|
m.Qos.ShouldBe(1);
|
||||||
|
m.RetainSeed.ShouldBe(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Round_trip_preserves_every_plain_field()
|
||||||
|
{
|
||||||
|
var m = new MqttTagConfigModel
|
||||||
|
{
|
||||||
|
Topic = "line3/press",
|
||||||
|
PayloadFormat = MqttPayloadFormat.Scalar,
|
||||||
|
JsonPath = "$.a.b",
|
||||||
|
DataType = DriverDataType.Int32,
|
||||||
|
Qos = 2,
|
||||||
|
RetainSeed = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
var m2 = MqttTagConfigModel.FromJson(m.ToJson());
|
||||||
|
|
||||||
|
m2.Topic.ShouldBe("line3/press");
|
||||||
|
m2.PayloadFormat.ShouldBe(MqttPayloadFormat.Scalar);
|
||||||
|
m2.JsonPath.ShouldBe("$.a.b");
|
||||||
|
m2.DataType.ShouldBe(DriverDataType.Int32);
|
||||||
|
m2.Qos.ShouldBe(2);
|
||||||
|
m2.RetainSeed.ShouldBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToJson_emits_camelCase_keys_with_enum_names_and_no_identity_key()
|
||||||
|
{
|
||||||
|
var json = new MqttTagConfigModel
|
||||||
|
{
|
||||||
|
Topic = "a/b",
|
||||||
|
PayloadFormat = MqttPayloadFormat.Raw,
|
||||||
|
DataType = DriverDataType.Boolean,
|
||||||
|
}.ToJson();
|
||||||
|
|
||||||
|
json.ShouldContain("\"topic\":\"a/b\"");
|
||||||
|
// Enums MUST serialize as names — the driver factory reads them strictly by name, and an
|
||||||
|
// ordinal would be rejected outright (the systemic enum-serialization trap).
|
||||||
|
json.ShouldContain("\"payloadFormat\":\"Raw\"");
|
||||||
|
json.ShouldContain("\"dataType\":\"Boolean\"");
|
||||||
|
json.ShouldNotContain("\"payloadFormat\":1");
|
||||||
|
json.ShouldNotContain("\"dataType\":0");
|
||||||
|
// TagConfig carries no identity under v3 — the tag's RawPath is the driver-side key.
|
||||||
|
json.ShouldNotContain("FullName", Case.Sensitive);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToJson_omits_absent_optional_keys()
|
||||||
|
{
|
||||||
|
var json = new MqttTagConfigModel { Topic = "a/b", PayloadFormat = MqttPayloadFormat.Raw }.ToJson();
|
||||||
|
|
||||||
|
// Absent stays absent so the driver's own defaults (qos ⇒ DefaultQos, retainSeed ⇒ true,
|
||||||
|
// jsonPath ⇒ "$") apply, rather than this editor freezing them into the blob.
|
||||||
|
json.ShouldNotContain("qos");
|
||||||
|
json.ShouldNotContain("retainSeed");
|
||||||
|
json.ShouldNotContain("jsonPath");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromJson_then_ToJson_preserves_unknown_keys()
|
||||||
|
{
|
||||||
|
var json = MqttTagConfigModel.FromJson(
|
||||||
|
"""
|
||||||
|
{"topic":"a/b","payloadFormat":"Raw","dataType":"String",
|
||||||
|
"isHistorized":true,"historianTagname":"Plant.A.B",
|
||||||
|
"array":{"valueRank":1,"dimensions":[4]},
|
||||||
|
"alarm":{"type":"Hi","limit":90.5},
|
||||||
|
"someFutureScalar":"keep me"}
|
||||||
|
""").ToJson();
|
||||||
|
|
||||||
|
var o = JsonNode.Parse(json)!.AsObject();
|
||||||
|
|
||||||
|
// History intent is authored by a different seam (TagHistorizeConfig) — dropping it here would
|
||||||
|
// silently un-historize the tag on the next edit.
|
||||||
|
o["isHistorized"]!.GetValue<bool>().ShouldBeTrue();
|
||||||
|
o["historianTagname"]!.GetValue<string>().ShouldBe("Plant.A.B");
|
||||||
|
// Nested objects/arrays must survive structurally, not just as scalars.
|
||||||
|
o["array"]!["valueRank"]!.GetValue<int>().ShouldBe(1);
|
||||||
|
o["array"]!["dimensions"]!.AsArray()[0]!.GetValue<int>().ShouldBe(4);
|
||||||
|
o["alarm"]!["limit"]!.GetValue<double>().ShouldBe(90.5);
|
||||||
|
o["someFutureScalar"]!.GetValue<string>().ShouldBe("keep me");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromJson_then_ToJson_preserves_sparkplug_keys_task24_will_own()
|
||||||
|
{
|
||||||
|
var json = MqttTagConfigModel.FromJson(
|
||||||
|
"""{"groupId":"Plant1","edgeNodeId":"E1","deviceId":"D1","metricName":"Temp"}""").ToJson();
|
||||||
|
|
||||||
|
var o = JsonNode.Parse(json)!.AsObject();
|
||||||
|
o["groupId"]!.GetValue<string>().ShouldBe("Plant1");
|
||||||
|
o["edgeNodeId"]!.GetValue<string>().ShouldBe("E1");
|
||||||
|
o["deviceId"]!.GetValue<string>().ShouldBe("D1");
|
||||||
|
o["metricName"]!.GetValue<string>().ShouldBe("Temp");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromJson_infers_SparkplugB_mode_from_the_descriptor_keys()
|
||||||
|
=> MqttTagConfigModel
|
||||||
|
.FromJson("""{"groupId":"Plant1","edgeNodeId":"E1","metricName":"Temp"}""")
|
||||||
|
.Mode.ShouldBe(MqttMode.SparkplugB);
|
||||||
|
|
||||||
|
// ── Validate: Plain rules ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_ValidPlain_ReturnsNull()
|
||||||
|
=> MqttTagConfigModel.FromJson(
|
||||||
|
"""{"topic":"a/b","payloadFormat":"Raw","dataType":"String"}""")
|
||||||
|
.Validate().ShouldBeNull();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_MissingTopic_Fails()
|
||||||
|
=> MqttTagConfigModel.FromJson("""{"payloadFormat":"Raw","dataType":"String"}""")
|
||||||
|
.Validate().ShouldNotBeNullOrWhiteSpace();
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("a/+/c")]
|
||||||
|
[InlineData("a/#")]
|
||||||
|
public void Validate_PlainWildcardTopic_Fails(string topic)
|
||||||
|
=> MqttTagConfigModel.FromJson($$"""{"topic":"{{topic}}","payloadFormat":"Raw","dataType":"String"}""")
|
||||||
|
.Validate().ShouldNotBeNullOrWhiteSpace();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_JsonWithoutPath_Fails()
|
||||||
|
=> MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Json","dataType":"Float64"}""")
|
||||||
|
.Validate().ShouldNotBeNullOrWhiteSpace();
|
||||||
|
|
||||||
|
// Falsifiability control for the test above: the SAME blob with a jsonPath must validate clean, so
|
||||||
|
// the failure above is provably the missing jsonPath and not the topic or the dataType.
|
||||||
|
[Fact]
|
||||||
|
public void Validate_JsonWithPath_ReturnsNull()
|
||||||
|
=> MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Json","jsonPath":"$","dataType":"Float64"}""")
|
||||||
|
.Validate().ShouldBeNull();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_NonJsonFormat_DoesNotRequireJsonPath()
|
||||||
|
=> MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Scalar","dataType":"Float64"}""")
|
||||||
|
.Validate().ShouldBeNull();
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("3")]
|
||||||
|
[InlineData("-1")]
|
||||||
|
[InlineData("\"high\"")]
|
||||||
|
[InlineData("1.5")]
|
||||||
|
public void Validate_InvalidQos_Fails(string qosLiteral)
|
||||||
|
=> MqttTagConfigModel.FromJson($$"""{"topic":"a/b","payloadFormat":"Raw","dataType":"String","qos":{{qosLiteral}}}""")
|
||||||
|
.Validate().ShouldNotBeNullOrWhiteSpace();
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(1)]
|
||||||
|
[InlineData(2)]
|
||||||
|
public void Validate_LegalQos_ReturnsNull(int qos)
|
||||||
|
=> MqttTagConfigModel.FromJson($$"""{"topic":"a/b","payloadFormat":"Raw","dataType":"String","qos":{{qos}}}""")
|
||||||
|
.Validate().ShouldBeNull();
|
||||||
|
|
||||||
|
// The driver factory reads payloadFormat/dataType STRICTLY — a typo is rejected outright
|
||||||
|
// (BadNodeIdUnknown), never defaulted. The editor must not let such a blob past save while
|
||||||
|
// silently retyping it, so Validate reads the ORIGINAL key bag, not the defaulted typed field.
|
||||||
|
[Fact]
|
||||||
|
public void Validate_TypoedPayloadFormat_Fails()
|
||||||
|
=> MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Jason","dataType":"String"}""")
|
||||||
|
.Validate().ShouldNotBeNullOrWhiteSpace();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_TypoedDataType_Fails()
|
||||||
|
=> MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Raw","dataType":"Double"}""")
|
||||||
|
.Validate().ShouldNotBeNullOrWhiteSpace();
|
||||||
|
|
||||||
|
// Once the editor has round-tripped the blob the typo is gone (ToJson rewrites the key with the
|
||||||
|
// typed field's name), so the strict check above can never wedge a tag the editor itself produced.
|
||||||
|
// NB a payloadFormat typo repairs to the Json default, which then legitimately demands a jsonPath —
|
||||||
|
// so the typo-repair claim is isolated here on dataType, whose default carries no follow-on rule.
|
||||||
|
[Fact]
|
||||||
|
public void Validate_AfterEditorRoundTrip_ClearsATypoedEnum()
|
||||||
|
{
|
||||||
|
var typoed = """{"topic":"a/b","payloadFormat":"Raw","dataType":"Double"}""";
|
||||||
|
MqttTagConfigModel.FromJson(typoed).Validate().ShouldNotBeNullOrWhiteSpace(); // pre-condition
|
||||||
|
|
||||||
|
var repaired = MqttTagConfigModel.FromJson(typoed).ToJson();
|
||||||
|
|
||||||
|
repaired.ShouldContain("\"dataType\":\"String\"");
|
||||||
|
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.
|
||||||
|
[Fact]
|
||||||
|
public void Validate_SparkplugMode_IsNotSubjectToPlainRules()
|
||||||
|
=> MqttTagConfigModel.FromJson("""{"groupId":"P1","edgeNodeId":"E1","metricName":"Temp"}""")
|
||||||
|
.Validate().ShouldBeNull();
|
||||||
|
|
||||||
|
// ── Dispatch registration ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TagConfigEditorMap_resolves_the_Mqtt_editor()
|
||||||
|
=> TagConfigEditorMap.Resolve("Mqtt").ShouldBe(
|
||||||
|
typeof(AdminUI.Components.Shared.Uns.TagEditors.MqttTagConfigEditor));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TagConfigValidator_dispatches_Mqtt()
|
||||||
|
=> TagConfigValidator.Validate("Mqtt", """{"payloadFormat":"Raw"}""").ShouldNotBeNullOrWhiteSpace();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user