@@ -0,0 +1,396 @@
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.
/// 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
{
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" ;
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.
/// Seeded into an unauthored tag's field so the "no extraction needed" case is one click away
/// rather than something the operator has to know.
/// </summary>
private const string RootJsonPath = "$" ;
/// <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.
/// </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 is
/// legal and NOT a validation failure — the key is omitted and the driver applies the document
/// root (<c>$</c>), which is the real "publisher puts a bare JSON scalar on the topic" case.
/// An <b>unauthored</b> tag is seeded with <c>$</c> by <see cref="FromJson"/> so that common case
/// is visible and one click away; see <see cref="RootJsonPath"/>.
/// </summary>
public string JsonPath { get ; set ; } = "" ;
/// <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. 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. 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>
/// 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>
/// <remarks>
/// An <b>unauthored</b> Plain tag — no <c>topic</c> AND no <c>jsonPath</c>, i.e. a brand-new tag
/// rather than an existing one the operator deliberately left path-less — has its
/// <see cref="JsonPath"/> seeded to <see cref="RootJsonPath"/>. The condition is deliberately
/// narrow: an existing tag that carries a topic and no <c>jsonPath</c> keeps the key ABSENT
/// through a load→save, so this can never rewrite already-deployed blobs.
/// </remarks>
/// <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 ) ;
var topic = TagConfigJson . GetString ( o , TopicKey ) ? ? "" ;
var jsonPath = TagConfigJson . GetString ( o , JsonPathKey ) ? ? "" ;
var mode = InferMode ( o ) ;
if ( mode = = MqttMode . Plain & & topic . Length = = 0 & & jsonPath . Length = = 0 ) { jsonPath = RootJsonPath ; }
return new MqttTagConfigModel
{
Mode = mode ,
Topic = topic ,
PayloadFormat = TagConfigJson . GetEnum ( o , PayloadFormatKey , MqttPayloadFormat . Json ) ,
JsonPath = jsonPath ,
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 ,
} ;
}
/// <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>
/// <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 ( )
{
// Blank ⇒ omit, like every other optional here: toggling the shape dropdown on an untouched tag
// 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 , 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 ) ;
}
/// <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>
/// <para>
/// Exactly ONE rule is deliberately <b>stricter</b> than the runtime parser: a wildcard topic,
/// which the runtime accepts and <c>Inspect</c> only warns about at deploy. It earns the
/// strictness because a wildcard has no legitimate single-Tag interpretation — one Tag holds one
/// value, and <c>+</c>/<c>#</c> would feed it from many source topics. Everything else —
/// required topic, strict enums, QoS 0– 2 — matches <c>MqttTagDefinitionFactory</c> exactly.
/// </para>
/// <para>
/// A blank <c>jsonPath</c> under a <c>Json</c> payload is explicitly <b>accepted</b>, matching
/// the runtime's document-root default. Rejecting it would make this editor refuse a config the
/// driver handles happily — inverting the "authoring surface accepts ⇔ publish accepts"
/// principle — and, because this validator also gates the CSV-import review grid
/// (<c>RawManualTagEntryModal</c>), would block whole import batches over a sane default. The
/// operator is guided by the seeded <c>$</c> from <see cref="FromJson"/> instead of a blocker.
/// </para>
/// <para>
/// 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.
/// </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 ( )
{
if ( Mode = = MqttMode . SparkplugB ) { return ValidateSparkplug ( ) ; }
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." ;
}
// NB no jsonPath rule — see the remarks above. A blank path is the driver's document-root
// default, not an authoring error.
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.
/// </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>
/// 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
/// 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." ;
}
}