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

Closed
opened 2026-07-27 13:49:45 -04:00 by dohertj2 · 1 comment
Owner

MqttDriver ships read/subscribe/discover only, so every MQTT node materializes read-only. This was
deferred by design (see the plan's Deferred / out of scope section and design §3.7 / §10 P3), not missed —
this issue tracks it.

Shape, from the design:

  • Plain mode — publish to a configured write topic (distinct from the read topic; a tag may legitimately
    read .../state and write .../set).
  • Sparkplug mode — encode an NCMD (edge-node metric) or DCMD (device metric). The encoder already
    exists: SparkplugCodec encodes today for the rebirth NCMD path, so the write leg reuses it rather than
    adding a second encoder.
  • Optimistic-Good + self-correct on echo — MQTT gives no write acknowledgement, so the pattern mirrors
    Galaxy's fire-and-forget: assume Good, and let the broker's echoed value correct the node. Note this
    interacts with write-outcome self-correction (#5): Galaxy can never surface a write failure for the same
    reason, and MQTT will inherit that property.
  • WriteIdempotent default-off — a Sparkplug command is not necessarily idempotent, so the resilience
    pipeline must not retry it by default.

Do not skip: MqttDriver currently does not implement IWritable at all, which is what makes the nodes
read-only. Adding the interface without the topic config would make nodes appear writable and silently
no-op — worse than the current honest read-only.

Acceptance. Write via both NodeIds (raw + UNS — v3 routes either to the same driver ref under the same
realm-qualified WriteOperate gate), verified live against the Mosquitto fixture for Plain and against the
C# edge-node simulator for NCMD/DCMD.

`MqttDriver` ships read/subscribe/discover only, so **every MQTT node materializes read-only**. This was deferred by design (see the plan's *Deferred / out of scope* section and design §3.7 / §10 P3), not missed — this issue tracks it. **Shape, from the design:** - **Plain mode** — publish to a configured write topic (distinct from the read topic; a tag may legitimately read `.../state` and write `.../set`). - **Sparkplug mode** — encode an **NCMD** (edge-node metric) or **DCMD** (device metric). The encoder already exists: `SparkplugCodec` encodes today for the rebirth NCMD path, so the write leg reuses it rather than adding a second encoder. - **Optimistic-Good + self-correct on echo** — MQTT gives no write acknowledgement, so the pattern mirrors Galaxy's fire-and-forget: assume Good, and let the broker's echoed value correct the node. Note this interacts with write-outcome self-correction (#5): Galaxy can never surface a write failure for the same reason, and MQTT will inherit that property. - **`WriteIdempotent` default-off** — a Sparkplug command is not necessarily idempotent, so the resilience pipeline must not retry it by default. **Do not skip:** `MqttDriver` currently does not implement `IWritable` at all, which is what makes the nodes read-only. Adding the interface without the topic config would make nodes appear writable and silently no-op — worse than the current honest read-only. **Acceptance.** Write via **both** NodeIds (raw + UNS — v3 routes either to the same driver ref under the same realm-qualified `WriteOperate` gate), verified live against the Mosquitto fixture for Plain and against the C# edge-node simulator for NCMD/DCMD.
dohertj2 added the driver/mqtt label 2026-07-27 13:49:45 -04:00
Author
Owner

Implemented in master 2a7d1167, live gate passed.

Mode Destination Payload
Plain the tag's new writeTopic UTF-8 text (Raw/Scalar) or a bare JSON literal (Json)
Sparkplug DCMD for a device metric, NCMD for an edge-node metric metric by name, explicit Datatype, no seq

Both QoS 0, retain:false — retain deliberately not configurable, for the reason RebirthRequester
already documents: a retained command is replayed on every future subscribe, including the device's own
reconnect.

The "do not skip" warning drove the design. writeTopic is a separate key and its absence is
meaningful
: BadNotWritable, and the node discovers ViewOnly. Browse-time writability is computed
by the same encoder the write path uses, so a node can never advertise writability it would then
refuse. Reusing the read topic was rejected outright — it would echo back and read as a confirmed device
value.

Optimistic-Good as specified, and it does inherit Galaxy's property: a failed device write can never
be surfaced. A failed publish is reported Bad. WriteIdempotent stays default-off.

Live gate — 21/21 against the real Mosquitto fixture, 6 of them new, each observing the published
message with an independent broker subscriber (a fake asserting "publish was called" could not
distinguish working from publishing to the wrong topic). Acceptance asked for both NodeIds; routing via
raw/UNS is DriverHostActor's shared write path, unchanged by this driver-level change.

The gate caught something worth knowing: the NCMD test failed first time because NCMD is a shared
channel
— the driver publishes its own Node Control/Rebirth on the exact topic an edge-node metric
write uses, and it arrived first. Correct driver behaviour, wrong test. Now matched on metric name, and
documented in docs/drivers/Mqtt.md, because anything consuming that topic needs to know. DCMD has no
contention.

Not covered: a Json write emits a bare JSON literal — jsonPath is a read-side extraction from a
different topic, and reconstructing a wrapped document from it would be guesswork.

Implemented in master `2a7d1167`, **live gate passed**. | Mode | Destination | Payload | |---|---|---| | Plain | the tag's new `writeTopic` | UTF-8 text (`Raw`/`Scalar`) or a bare JSON literal (`Json`) | | Sparkplug | `DCMD` for a device metric, `NCMD` for an edge-node metric | metric **by name**, explicit `Datatype`, no `seq` | Both QoS 0, `retain:false` — retain deliberately not configurable, for the reason `RebirthRequester` already documents: a retained command is replayed on every future subscribe, including the device's own reconnect. **The "do not skip" warning drove the design.** `writeTopic` is a separate key and its **absence is meaningful**: `BadNotWritable`, and the node discovers `ViewOnly`. Browse-time writability is computed by the *same encoder the write path uses*, so a node can never advertise writability it would then refuse. Reusing the read topic was rejected outright — it would echo back and read as a confirmed device value. Optimistic-Good as specified, and it does inherit Galaxy's property: a failed **device** write can never be surfaced. A failed **publish** is reported Bad. `WriteIdempotent` stays default-off. **Live gate — 21/21 against the real Mosquitto fixture**, 6 of them new, each observing the published message with an *independent* broker subscriber (a fake asserting "publish was called" could not distinguish working from publishing to the wrong topic). Acceptance asked for both NodeIds; routing via raw/UNS is `DriverHostActor`'s shared write path, unchanged by this driver-level change. **The gate caught something worth knowing:** the NCMD test failed first time because **NCMD is a shared channel** — the driver publishes its own `Node Control/Rebirth` on the exact topic an edge-node metric write uses, and it arrived first. Correct driver behaviour, wrong test. Now matched on metric name, and documented in `docs/drivers/Mqtt.md`, because anything consuming that topic needs to know. DCMD has no contention. Not covered: a `Json` write emits a bare JSON literal — `jsonPath` is a read-side extraction from a *different* topic, and reconstructing a wrapped document from it would be guesswork.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: dohertj2/lmxopcua#508