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:
@@ -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.OpcUaClient] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor),
|
||||
[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>
|
||||
|
||||
@@ -23,6 +23,7 @@ public static class TagConfigValidator
|
||||
[DriverTypeNames.FOCAS] = j => FocasTagConfigModel.FromJson(j).Validate(),
|
||||
[DriverTypeNames.OpcUaClient] = j => OpcUaClientTagConfigModel.FromJson(j).Validate(),
|
||||
[DriverTypeNames.Calculation] = j => CalculationTagConfigModel.FromJson(j).Validate(),
|
||||
[DriverTypeNames.Mqtt] = j => MqttTagConfigModel.FromJson(j).Validate(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user