feat(mqtt): write-through (IWritable) — Plain publish + Sparkplug NCMD/DCMD (#508)

MqttDriver implemented no IWritable, so every MQTT node materialized read-only
regardless of what the operator authored. It now publishes.

  Plain      -> the tag's writeTopic; UTF-8 text (Raw/Scalar) or a bare JSON
                literal (Json)
  Sparkplug  -> DCMD for a device metric, NCMD for an edge-node metric, carrying
                the metric BY NAME with an explicit Datatype and no seq

Both at QoS 0, retain:false. Retain is deliberately not configurable — a retained
command is replayed by the broker on every future subscribe, including the
device's own reconnect, so one stale write would be re-applied forever.

writeTopic is a new, separate TagConfig key, and its ABSENCE is meaningful: the
write is refused BadNotWritable and the node is discovered ViewOnly. The issue's
"do not skip" warning drove the design — the one outcome worse than read-only
nodes is a node that looks writable, returns Good and publishes nothing. Reusing
the read topic would be worse still: it would echo back and read as a confirmed
device value. Browse-time writability is computed by the SAME encoder the write
path uses, so a node cannot advertise writability it would then refuse.

Good means published, not applied — MQTT has no application-level write ack, so
this is Galaxy's optimistic-Good posture and inherits its property: a failed
DEVICE write can never be surfaced. A failed PUBLISH is reported Bad; the
optimism covers what the device did with the command, never whether it was sent.
WriteIdempotent stays default-off.

The encoder is pure and lives in .Contracts, so both wire shapes are testable
without a broker. Unrepresentable values and types with no Sparkplug encoding are
refused rather than coerced — a Reference must not silently become a String on
the wire, and "not-a-number" must not become 0 on a live plant.

LIVE GATE PASSED against the real Mosquitto fixture on 10.100.0.35: 21/21
integration tests, including 6 new ones that observe the published message with
an INDEPENDENT broker subscriber — a fake asserting "publish was called" could
not tell the feature working from the feature publishing to the wrong topic.

The gate earned its keep: the NCMD test initially failed because NCMD is a SHARED
channel — the driver publishes its own rebirth request (Node Control/Rebirth) on
the very topic an edge-node metric write uses, and it arrived first. Correct
driver behaviour, wrong test; the watcher now matches on metric name. Documented,
because anything consuming that topic needs to know. DCMD has no contention.

Offline: 24 encoder + 9 driver tests. The four new status constants are NAMED so
StatusCodeParityTests can see them (#497/#519) — it went 258 -> 262 and validated
each against the pinned SDK.

Also corrects the Mqtt.md "Known gaps" table: prunes the six rows closed in this
round, and fixes the #507 row, which still described rediscovery as inert after
that issue was closed as superseded and its injection path deleted.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
Joseph Doherty
2026-07-31 06:33:23 -04:00
parent 7f53bd96ec
commit 72be19225d
11 changed files with 1441 additions and 24 deletions
@@ -30,6 +30,18 @@
@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-8">
<label class="form-label" for="mqtt-write-topic">Write topic <span class="text-muted">(optional)</span></label>
<input id="mqtt-write-topic" type="text" class="form-control form-control-sm mono"
placeholder="factory/oven/setpoint/set" value="@_m.WriteTopic"
@onchange="@(e => Update(() => _m.WriteTopic = e.Value?.ToString() ?? ""))" />
<div class="form-text">
Where a write is published. Deliberately separate from the read topic — a tag commonly
reads <code>&hellip;/state</code> and writes <code>&hellip;/set</code>. Leave blank for a
read-only tag: a write is then refused (<code>BadNotWritable</code>) rather than published
nowhere.
</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"
@@ -34,6 +34,7 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
public sealed class MqttTagConfigModel
{
private const string TopicKey = "topic";
private const string WriteTopicKey = "writeTopic";
private const string PayloadFormatKey = "payloadFormat";
private const string JsonPathKey = "jsonPath";
private const string DataTypeKey = "dataType";
@@ -80,6 +81,15 @@ public sealed class MqttTagConfigModel
/// <summary>The concrete MQTT topic the tag subscribes to (Plain mode). Required; no wildcards.</summary>
public string Topic { get; set; } = "";
/// <summary>
/// The topic a write PUBLISHES to (Plain mode), or blank when the tag is read-only (Gitea #508).
/// Deliberately distinct from <see cref="Topic"/> — a tag legitimately reads <c>.../state</c> and
/// writes <c>.../set</c> — and blank is meaningful: the driver refuses the write with
/// <c>BadNotWritable</c> rather than publishing nowhere. Unused in Sparkplug mode, where a
/// command is addressed by the binding tuple (NCMD/DCMD), not by a typed topic.
/// </summary>
public string WriteTopic { get; set; } = "";
/// <summary>How the received payload is decoded (Plain mode).</summary>
public MqttPayloadFormat PayloadFormat { get; set; } = MqttPayloadFormat.Json;
@@ -170,6 +180,7 @@ public sealed class MqttTagConfigModel
{
Mode = mode,
Topic = topic,
WriteTopic = TagConfigJson.GetString(o, WriteTopicKey) ?? "",
PayloadFormat = TagConfigJson.GetEnum(o, PayloadFormatKey, MqttPayloadFormat.Json),
JsonPath = jsonPath,
DataType = TagConfigJson.GetEnum(o, DataTypeKey, DriverDataType.String),
@@ -210,6 +221,11 @@ public sealed class MqttTagConfigModel
// must not leave a stray "topic":"" behind.
TagConfigJson.Set(_bag, TopicKey, string.IsNullOrWhiteSpace(Topic) ? null : Topic.Trim());
// Blank ⇒ omit ⇒ the tag is not writable. Written in BOTH modes, unlike payloadFormat: a
// Sparkplug tag ignores it, but silently deleting an operator's typed topic when they flip the
// shape dropdown would lose authored configuration rather than merely tidy the blob.
TagConfigJson.Set(_bag, WriteTopicKey, string.IsNullOrWhiteSpace(WriteTopic) ? null : WriteTopic.Trim());
// Plain-only (Gitea #513). Unlike every other Plain key here, PayloadFormat is a NON-NULLABLE
// typed field, so an unconditional write does not merely preserve a retyped tag's leftover — it
// INVENTS "payloadFormat":"Json" in the blob of a Sparkplug tag that never had one. The key names
@@ -294,6 +310,15 @@ public sealed class MqttTagConfigModel
+ "or the tag would be fed by every matching topic.";
}
// A write topic is OPTIONAL (blank = read-only), but a present one must be publishable: a
// wildcard cannot be published to at all, so it would fail at the broker rather than here.
var writeTopic = WriteTopic.Trim();
if (writeTopic.Length > 0 && writeTopic.IndexOfAny(TopicWildcards) >= 0)
{
return $"Write topic '{writeTopic}' contains an MQTT wildcard (+ or #); a write is published "
+ "to one concrete 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;