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,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);
}
}