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:
Joseph Doherty
2026-07-24 17:17:37 -04:00
parent 420692b6e5
commit 36abd871a1
5 changed files with 613 additions and 0 deletions
@@ -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 (02), 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 02 — 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 02 ⇒ 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>