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"