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:
+12
@@ -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>…/state</code> and writes <code>…/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;
|
||||
|
||||
Reference in New Issue
Block a user