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
+98 -17
View File
@@ -24,20 +24,24 @@ for the design.
|---|---|
| `Driver.Mqtt.Contracts` | Config records, enums, `MqttTagDefinition` + the pure `MqttTagDefinitionFactory`, and the one shared `MqttJson.Options`. **Deliberately transport-free** — no MQTTnet reference |
| `Driver.Mqtt` | Runtime: `MqttDriver`, `MqttConnection` (connect + reconnect supervisor), `MqttSubscriptionManager`, `LastValueCache`, factory registration, `MqttDriverProbe` |
| `Driver.Mqtt.Transport` | `MqttClientOptionsFactory` — TLS posture, PEM CA-pin chain validator, credentials, protocol mapping. Connection **policy** only; nothing here connects |
| `Driver.Mqtt.Browser` | `MqttDriverBrowser` + `MqttBrowseSession` — the AdminUI address picker's `#`-observation session |
> `.Browser` references `.Driver` (not just `.Contracts`) — a **documented, deliberate exception**
> so browse can reuse `MqttConnection.BuildClientOptions` (TLS / CA-pin / credentials / protocol
> mapping). Its cost is pulling `Core` into the AdminUI graph. The csproj carries a boxed warning;
> do not copy the pattern into a new driver's browser. The clean fix — extract the pure
> options builder down into `.Contracts` — is tracked as
> [#512](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/512).
> **`.Browser` depends on the shared leaves only** — `.Contracts` + `.Transport`, never the runtime
> `.Driver`. It used to reference `.Driver` under a documented exception; #512 removed it via two
> extractions (the client-options builder to `.Transport`, and the Sparkplug protocol layer —
> `SparkplugTopic`, `SparkplugCodec`, `RebirthRequester` — to `.Contracts`, beside the Tahu wire
> schema they encode). `MqttBrowserLayeringTests` guards the graph in both directions.
>
> There must stay **exactly one copy** of the TLS/CA-pin logic. A second copy would not break the
> build — it would silently diverge, browse quietly not honouring a pinned CA while the driver does.
## Capability Surface
```csharp
public sealed class MqttDriver
: IDriver, ITagDiscovery, ISubscribable, IReadable, IHostConnectivityProbe, IRediscoverable, IAsyncDisposable, IDisposable
: IDriver, ITagDiscovery, ISubscribable, IReadable, IWritable, IHostConnectivityProbe, IRediscoverable,
IAsyncDisposable, IDisposable
```
| Capability | Entry point | Notes |
@@ -46,12 +50,15 @@ public sealed class MqttDriver
| `ITagDiscovery` | `DiscoverAsync` | Replays **only the authored `rawTags`** into an `Mqtt` folder. `SupportsOnlineDiscovery => false`. `RediscoverPolicy` is `Once` in Plain and **`UntilStable` in Sparkplug** (half of what a Sparkplug tag needs — its datatype — is only knowable from a birth) |
| `ISubscribable` | `SubscribeAsync` / `OnDataChange` | One SUBSCRIBE per distinct authored topic. **`publishingInterval` is ignored** — MQTT is push-based; this driver neither polls nor throttles |
| `IReadable` | `ReadAsync` | Serves the last received/retained value from `LastValueCache`; never throws. A never-seen tag reads `BadWaitingForInitialData` |
| `IWritable` | `WriteAsync` | Publishes to the tag's **`writeTopic`** (Plain) or as an **NCMD/DCMD** command (Sparkplug). One status per request; a tag with no write destination is refused `BadNotWritable`. See [Write-through](#write-through) |
| `IHostConnectivityProbe` | `GetHostStatuses` | 1-second poll of `MqttConnection.State`; `HostName` is `"{host}:{port}"` |
| `IRediscoverable` | `OnRediscoveryNeeded` | Nothing raises it in Plain mode. **Sparkplug raises it** when a birth for an *authored* scope carries a metric-name set different from the last one seen for that scope — both conditions are anti-storm, see [Rediscovery](#rediscovery-sparkplug) |
**Not implemented:** `IWritable`, `IPerCallHostResolver`, `IAlarmSource`, `IHistoryProvider`.
Materialized nodes are `SecurityClassification.ViewOnly`, `IsAlarm: false`, `IsHistorized: false` —
*"MQTT ingest is one-way … ViewOnly rather than an Operate node whose writes would silently vanish."*
**Not implemented:** `IPerCallHostResolver`, `IAlarmSource`, `IHistoryProvider`.
Materialized nodes are `IsAlarm: false`, `IsHistorized: false`. A node is
`SecurityClassification.Operate` when its tag declares a write destination and
`ViewOnly` when it does not — the browse-time answer comes from the same encoder the write path uses,
so a node can never advertise writability it would then refuse.
Driver-type string: **`Mqtt`** (`DriverTypeNames.Mqtt`).
@@ -274,6 +281,81 @@ nothing forever, so `OnReconnectedAsync` **throws** when zero filters are grante
down rather than swallowing it. A SUBACK failure degrades just the affected RawPaths to
`BadCommunicationError`.
## Write-through
`IWritable` landed with [#508](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/508). Before it,
every MQTT node materialized read-only regardless of what the operator authored.
| Mode | Destination | Payload |
|---|---|---|
| **Plain** | the tag's `writeTopic` | the value as UTF-8 text (`Raw`/`Scalar`) or a bare JSON literal (`Json`) |
| **Sparkplug B** | `spBv1.0/{group}/DCMD/{node}/{device}` for a device metric, `spBv1.0/{group}/NCMD/{node}` for an edge-node metric | a Sparkplug `Payload` carrying the metric **by name**, with an explicit `Datatype` and no `seq` |
Both publish at **QoS 0, `retain: false`**. Retain is the one that matters and is deliberately not
configurable: a retained command would be replayed by the broker to the device on *every* future
subscribe — including the device's own reconnect — so one stale write would be re-applied forever.
### `writeTopic` is separate from `topic`, and its absence is meaningful
A tag commonly reads `…/state` and writes `…/set`, so the destination is its own authored field. A
Plain tag with **no** `writeTopic` is refused `BadNotWritable` — it is not published anywhere, and the
node is discovered `ViewOnly`.
> This is the whole point of the design. The one outcome worse than read-only nodes is a node that
> *looks* writable, accepts a write, returns `Good`, and publishes nothing. Publishing back onto the
> tag's own read topic would be worse still: it would echo straight back and read as a confirmed
> device value.
```jsonc
{
"topic": "factory/oven/temp/state",
"writeTopic": "factory/oven/temp/set", // omit for a read-only tag
"payloadFormat": "Scalar",
"dataType": "Float64"
}
```
Sparkplug needs no extra key — a command is addressed by the binding tuple the tag already carries.
### `Good` means published, not applied
MQTT has no application-level write acknowledgement, so a successful PUBLISH is the strongest truth
available at this seam — the same optimistic-Good posture Galaxy's fire-and-forget write takes. Device
truth arrives afterwards as the echoed value on the read topic and corrects the node through ordinary
ingest (write-outcome self-correction, [#5](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/5)).
**Inherited consequence:** this driver can never surface a failed *device* write, exactly as Galaxy
cannot. A publish that itself fails **is** reported `Bad` — the optimism covers what the device did
with the command, never whether it was sent.
### Status codes
| Situation | Status |
|---|---|
| Published | `Good` |
| Reference is not an authored tag | `BadNodeIdUnknown` |
| No write destination, or a data type with no command encoding | `BadNotWritable` |
| Value not representable in the tag's declared type | `BadTypeMismatch` |
| Publish failed, or no live broker connection | `BadCommunicationError` |
One status per request, in request order; one bad request never fails the rest of the batch.
### `WriteIdempotent` stays default-off
MQTT gives no write ack and a Sparkplug command is not necessarily idempotent (a pulse, a recipe step,
a counter increment), so the resilience pipeline must not replay one. Operators opt in per tag.
### Two things to know
- **`jsonPath` is not applied on write.** It is a read-side extraction from the *read* topic's
document; the write topic is a different topic whose payload shape nothing in the tag config
describes. A `Json` write emits a bare JSON literal. Reconstructing a wrapped document from an
extraction path would be guesswork.
- **NCMD is a shared channel.** The driver publishes its own rebirth requests
(`Node Control/Rebirth`) on `spBv1.0/{group}/NCMD/{node}` — the same topic an edge-node metric write
uses. Anything consuming that topic must match on the metric name. The live gate caught exactly this:
a rebirth NCMD arrived before the write command. DCMD has no such contention.
## Browse
`MqttDriverBrowser` opens a **read-only observation session**: it subscribes at QoS 0 to `#` (or
@@ -384,14 +466,13 @@ identical words. See [TestConnectProbes.md](TestConnectProbes.md).
Every row is tracked. None is a silent failure — each either refuses in words or logs.
**Closed 2026-07-31:** write-through ([#508](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/508), live-gated), the slashed-metric browse-commit
([#509](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/509)), the stale rebirth affordance ([#510](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/510)), the `.Browser` layering exception
([#512](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/512)), the stray Sparkplug `payloadFormat` ([#513](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/513)) and rebirth on a
not-yet-birthed plant ([#514](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/514)).
| Gap | Issue | Effect | Notes |
|---|:---:|---|---|
| **Write-through (`IWritable`)** | [#508](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/508) | Every MQTT node is read-only | Deferred to P3 — NCMD/DCMD + plain publish, optimistic-Good + self-correct on echo |
| **Rediscovery is inert** | [#507](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/507) | A DBIRTH introducing a metric does not change the OPC UA tree; redeploy to pick it up | v3 **platform** gap, not MQTT-specific — nothing subscribes to `OnRediscoveryNeeded` and `DriverHostActor.HandleDiscoveredNodes` hard-returns. Also affects Galaxy / TwinCAT / AbCip. Driver-side decision is log-observable |
| **Rebirth needs a non-empty tree** | [#514](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/514) | `Request rebirth…` cannot be armed on a plant that has not birthed since the window opened | See [Refresh](#refresh). Session side already supports a bare `{group}/{edgeNode}` scope |
| **`_canRebirth` is captured at browse-open** | [#510](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/510) | A session reaped for idleness (2 min) still draws the button, and using it errors | The error is shown, not swallowed; reopen the browser |
| **Rediscovery is advisory, not a rebuild** | [#507](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/507) (closed, superseded) | A DBIRTH introducing a metric does not change the served OPC UA tree; an operator must re-browse the device and commit | **No longer "inert".** `DriverInstanceActor` now subscribes to `OnRediscoveryNeeded` and the raise rides the driver-health snapshot to the AdminUI `/hosts` page as a **re-browse chip**. It deliberately does not inject nodes: v3 authors raw tags through the `/raw` browse-commit flow, and grafting nodes at runtime would materialise ones nobody approved and no deployment artifact records. #507's injection path was deleted rather than revived |
| **Primary-host STATE publishing** | [#511](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/511) | `ActAsPrimaryHost` does nothing | Logs a startup warning rather than being silently inert — see the `Sparkplug` sub-object table |
| **A metric whose name contains `/` cannot be browse-committed** | [#509](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/509) | The commit is refused **whole**, in words (`Row N: Name must not contain '/'`) | The derived **tag Name** is a RawPath segment and may not contain `/`, while `metricName` legitimately may. This blocks the spec-mandatory `Node Control/Rebirth`. **Workaround: Manual entry** — author the tag with a `/`-free name and type the slashed `metricName` into the Sparkplug editor, which accepts it. Nothing is silently mis-bound |
| **`DataSet` / `Template` / `PropertySet` metrics** | [#515](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/515) | Skipped with a warning | v1-unsupported; never coerced into a guessed type |
| **The tag editor writes `payloadFormat` into a Sparkplug blob** | [#513](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/513) | Cosmetic only | `payloadFormat` is Plain-only; the factory routes on the Sparkplug keys and ignores it. Noise in the blob, not a mis-binding |
| **`.Browser` references `.Driver`, not just `.Contracts`** | [#512](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/512) | A documented layering exception | Taken to reuse `MqttConnection.BuildClientOptions` rather than duplicate TLS/CA-pin logic — see the note at the top of this doc |
@@ -26,6 +26,15 @@ public static class MqttTagConfigKeys
/// <summary>The concrete MQTT topic a Plain-mode tag subscribes to.</summary>
public const string Topic = "topic";
/// <summary>
/// The concrete MQTT topic a Plain-mode tag PUBLISHES to when written (Gitea #508). Deliberately
/// separate from <see cref="Topic"/>: a tag legitimately reads <c>.../state</c> and writes
/// <c>.../set</c>, and publishing a command onto the tag's own read topic would echo back and
/// read as a confirmed device value. Absent ⇒ the tag is not writable, and a write is refused
/// rather than silently dropped.
/// </summary>
public const string WriteTopic = "writeTopic";
/// <summary>The Sparkplug group id — the first segment of the tag's binding tuple.</summary>
public const string GroupId = "groupId";
@@ -60,6 +60,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
/// <see cref="DriverDataType.String"/> default. Both factories set it from the key's presence so it
/// means the same thing in either mode; nothing on the Plain ingest path reads it.
/// </param>
/// <param name="WriteTopic">
/// The concrete MQTT topic a <b>Plain</b>-mode write publishes to, or <see langword="null"/> when the
/// tag declares no write destination — in which case a write is refused with <c>BadNotWritable</c>
/// rather than silently dropped (Gitea #508). Unused in Sparkplug mode, where the command address is
/// the binding tuple itself (an NCMD/DCMD topic derived from group/edgeNode/device).
/// </param>
/// <param name="GroupId">The Sparkplug group id; <see langword="null"/> for a Plain-mode tag.</param>
/// <param name="EdgeNodeId">The Sparkplug edge-node id; <see langword="null"/> for a Plain-mode tag.</param>
/// <param name="DeviceId">
@@ -82,4 +88,5 @@ public sealed record MqttTagDefinition(
string? GroupId = null,
string? EdgeNodeId = null,
string? DeviceId = null,
string? MetricName = null);
string? MetricName = null,
string? WriteTopic = null);
@@ -104,7 +104,10 @@ public static class MqttTagDefinitionFactory
DataType: dataType,
Qos: qos,
RetainSeed: ReadBoolOrDefault(root, "retainSeed", defaultValue: true),
DataTypeAuthored: dataTypeAuthored);
DataTypeAuthored: dataTypeAuthored,
// Optional, and absence is meaningful: no writeTopic ⇒ not writable (Gitea #508).
// Blank normalises to null so "" and absent cannot mean different things.
WriteTopic: NullIfBlank(ReadString(root, MqttTagConfigKeys.WriteTopic)));
return true;
}
catch (JsonException) { return false; }
@@ -190,7 +193,10 @@ public static class MqttTagDefinitionFactory
GroupId: groupId,
EdgeNodeId: edgeNodeId,
DeviceId: deviceId,
MetricName: metricName);
MetricName: metricName,
// Carried through, but unread on the Sparkplug write path: a Sparkplug command is
// addressed by the binding tuple, not by a topic the operator typed.
WriteTopic: NullIfBlank(ReadString(root, MqttTagConfigKeys.WriteTopic)));
return true;
}
catch (JsonException) { return false; }
@@ -283,6 +289,17 @@ public static class MqttTagDefinitionFactory
? e.GetString() ?? ""
: "";
/// <summary>
/// Normalises a blank string to <see langword="null"/>, for the optional fields where absence is
/// itself meaningful — <c>writeTopic</c>, where "absent" means "not writable" (Gitea #508). Without
/// this, <c>"writeTopic": ""</c> and an absent key would take different code paths and an empty
/// string could reach the transport as a publish topic.
/// </summary>
/// <param name="value">The raw read value.</param>
/// <returns>The trimmed value, or <see langword="null"/> when blank.</returns>
private static string? NullIfBlank(string value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static bool ReadBoolOrDefault(JsonElement o, string name, bool defaultValue)
=> o.TryGetProperty(name, out var e) && e.ValueKind is JsonValueKind.True or JsonValueKind.False
? e.GetBoolean()
@@ -0,0 +1,304 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
using Google.Protobuf;
using Org.Eclipse.Tahu.Protobuf;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
using TahuDataType = Org.Eclipse.Tahu.Protobuf.DataType;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
/// <summary>
/// Why a write could not be encoded. Maps 1:1 onto the status the driver returns, so the reason a
/// write was refused is decided here — once, purely — rather than at the transport.
/// </summary>
public enum MqttWriteEncodeError
{
/// <summary>Encoding succeeded.</summary>
None = 0,
/// <summary>
/// The tag declares no write destination: a Plain tag with no <c>writeTopic</c>. Deliberately a
/// hard refusal — see <see cref="MqttWriteEncoder"/>'s remarks on why a silent no-op would be
/// worse than the honest read-only behaviour this replaced.
/// </summary>
NoWriteDestination,
/// <summary>The value is null, or cannot be represented in the tag's declared data type.</summary>
ValueNotRepresentable,
/// <summary>The tag's declared data type has no Sparkplug command encoding.</summary>
UnsupportedDataType,
}
/// <summary>
/// Encodes an outbound MQTT write — the payload bytes and the topic to publish them on — for both
/// ingest shapes (Gitea #508). Pure: no I/O, no transport, no clock beyond the Sparkplug command
/// timestamp, so the whole encode contract is unit-testable without a broker.
/// </summary>
/// <remarks>
/// <para>
/// <b>A missing write destination is a refusal, never a no-op.</b> Before #508 the driver
/// implemented no <c>IWritable</c> at all, so every MQTT node materialised read-only — honest, if
/// limited. The one outcome that would be strictly worse is a node that <em>looks</em> writable,
/// accepts a write, returns Good and publishes nothing. So a Plain tag with no <c>writeTopic</c>
/// yields <see cref="MqttWriteEncodeError.NoWriteDestination"/> and the driver surfaces
/// <c>BadNotWritable</c>.
/// </para>
/// <para>
/// <b>The write topic is deliberately separate from the read topic.</b> A tag legitimately reads
/// <c>.../state</c> and writes <c>.../set</c>, so the destination is its own authored field rather
/// than being inferred from <see cref="MqttTagDefinition.Topic"/>. Publishing a command back onto
/// the tag's own read topic would, on a broker with no device behind it, echo straight back and
/// read as a confirmed device value.
/// </para>
/// <para>
/// <b>Sparkplug writes are commands, not data.</b> A metric under a device encodes as a DCMD, one
/// owned by the edge node itself as an NCMD, addressed by the tag's stable
/// <c>(group, edgeNode, device?, metric)</c> tuple — never by the per-birth alias, which may be
/// reused for a different metric across a rebirth. No <c>seq</c> is stamped, for the reason
/// <see cref="RebirthRequester"/> documents at length: the sequence stream belongs to the messages
/// an edge node itself publishes, and a strict node validating a host-supplied one against its own
/// counter would have grounds to reject the command.
/// </para>
/// <para>
/// <b>jsonPath is not applied on write.</b> It is a read-side extraction from the read topic's
/// document, and the write topic is a different topic whose payload shape nothing in the tag config
/// describes. A <see cref="MqttPayloadFormat.Json"/> write therefore emits the value as a bare JSON
/// literal. Reconstructing a wrapped document from an extraction path would be guesswork, and
/// guessing a payload shape is how a write lands somewhere nobody authored.
/// </para>
/// </remarks>
public static class MqttWriteEncoder
{
/// <summary>QoS for an outbound write. See the remarks on <see cref="TryEncode"/>.</summary>
public const int WriteQos = 0;
/// <summary>
/// Encodes one write for the tag <paramref name="def"/> under the driver's <paramref name="mode"/>.
/// </summary>
/// <param name="mode">The driver's ingest shape — chosen by driver config, never sniffed from the tag.</param>
/// <param name="def">The resolved tag definition.</param>
/// <param name="value">The value to write.</param>
/// <param name="encoded">The topic + payload to publish, when this returns <see cref="MqttWriteEncodeError.None"/>.</param>
/// <returns>The encode outcome.</returns>
/// <remarks>
/// <b>QoS 0, retain <see langword="false"/>.</b> Retain is the one that matters: a retained
/// command would be replayed by the broker to the device on every future subscribe — including
/// the device's own reconnect — so a single stale write would be re-applied forever. This is the
/// same hazard <see cref="RebirthRequester"/> avoids for NCMD, and the reason neither is
/// configurable.
/// </remarks>
public static MqttWriteEncodeError TryEncode(
MqttMode mode,
MqttTagDefinition def,
object? value,
out MqttEncodedWrite encoded)
{
ArgumentNullException.ThrowIfNull(def);
encoded = default;
return mode == MqttMode.SparkplugB
? TryEncodeSparkplug(def, value, out encoded)
: TryEncodePlain(def, value, out encoded);
}
/// <summary>Encodes a Plain-mode write: the value's text/JSON rendering onto the tag's write topic.</summary>
/// <param name="def">The resolved tag definition.</param>
/// <param name="value">The value to write.</param>
/// <param name="encoded">The topic + payload on success.</param>
/// <returns>The encode outcome.</returns>
private static MqttWriteEncodeError TryEncodePlain(
MqttTagDefinition def,
object? value,
out MqttEncodedWrite encoded)
{
encoded = default;
if (string.IsNullOrWhiteSpace(def.WriteTopic))
{
return MqttWriteEncodeError.NoWriteDestination;
}
if (value is null)
{
// No MQTT payload means "the value is null" — a zero-length payload is a legitimate
// clear-the-retained-message instruction, which is a different operation entirely.
return MqttWriteEncodeError.ValueNotRepresentable;
}
string text;
if (def.PayloadFormat == MqttPayloadFormat.Json)
{
try
{
text = JsonSerializer.Serialize(value);
}
catch (NotSupportedException)
{
return MqttWriteEncodeError.ValueNotRepresentable;
}
}
else
{
// Raw and Scalar are both "the payload's UTF-8 text" on the read side; the write side
// mirrors that. Invariant culture, or a comma decimal separator on a European host would
// publish a number the device cannot parse.
text = value is IFormattable f
? f.ToString(null, CultureInfo.InvariantCulture)
: value.ToString() ?? "";
}
encoded = new MqttEncodedWrite(def.WriteTopic!.Trim(), Encoding.UTF8.GetBytes(text));
return MqttWriteEncodeError.None;
}
/// <summary>Encodes a Sparkplug-mode write as an NCMD (node metric) or DCMD (device metric).</summary>
/// <param name="def">The resolved tag definition.</param>
/// <param name="value">The value to write.</param>
/// <param name="encoded">The topic + payload on success.</param>
/// <returns>The encode outcome.</returns>
private static MqttWriteEncodeError TryEncodeSparkplug(
MqttTagDefinition def,
object? value,
out MqttEncodedWrite encoded)
{
encoded = default;
if (def.GroupId is not { Length: > 0 } groupId
|| def.EdgeNodeId is not { Length: > 0 } edgeNodeId
|| def.MetricName is not { Length: > 0 } metricName)
{
// A Sparkplug tag's command address IS its binding tuple, so an incomplete tuple is the
// Sparkplug equivalent of a missing write topic rather than a different kind of failure.
return MqttWriteEncodeError.NoWriteDestination;
}
if (value is null)
{
return MqttWriteEncodeError.ValueNotRepresentable;
}
var metric = new Payload.Types.Metric { Name = metricName };
var error = ApplyValue(metric, def.DataType, value);
if (error != MqttWriteEncodeError.None)
{
return error;
}
var timestampMs = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
metric.Timestamp = timestampMs;
var payload = new Payload { Timestamp = timestampMs };
payload.Metrics.Add(metric);
// A device metric is commanded with DCMD addressed to that device; an edge-node metric with
// NCMD. Sending a device metric as NCMD would address the node, which does not own it.
var isDevice = def.DeviceId is { Length: > 0 };
var topic = SparkplugTopic.Format(
groupId,
isDevice ? SparkplugMessageType.DCMD : SparkplugMessageType.NCMD,
edgeNodeId,
isDevice ? def.DeviceId : null);
encoded = new MqttEncodedWrite(topic, payload.ToByteArray());
return MqttWriteEncodeError.None;
}
/// <summary>
/// Sets the metric's typed value field and its <c>Datatype</c> from the tag's declared type.
/// </summary>
/// <param name="metric">The metric being built.</param>
/// <param name="dataType">The tag's declared driver data type.</param>
/// <param name="value">The value to convert.</param>
/// <returns>The encode outcome.</returns>
/// <remarks>
/// <c>Datatype</c> is set explicitly rather than left for the node to infer, for the same reason
/// <see cref="RebirthRequester"/> sets it: a node that treats a missing or ambiguous field as
/// malformed drops the command silently, and a silently-dropped write is the failure mode this
/// whole feature must not have.
/// </remarks>
private static MqttWriteEncodeError ApplyValue(
Payload.Types.Metric metric,
DriverDataType dataType,
object value)
{
try
{
switch (dataType)
{
case DriverDataType.Boolean:
metric.Datatype = (uint)TahuDataType.Boolean;
metric.BooleanValue = Convert.ToBoolean(value, CultureInfo.InvariantCulture);
return MqttWriteEncodeError.None;
case DriverDataType.Int16:
metric.Datatype = (uint)TahuDataType.Int16;
metric.IntValue = unchecked((uint)Convert.ToInt16(value, CultureInfo.InvariantCulture));
return MqttWriteEncodeError.None;
case DriverDataType.Int32:
metric.Datatype = (uint)TahuDataType.Int32;
metric.IntValue = unchecked((uint)Convert.ToInt32(value, CultureInfo.InvariantCulture));
return MqttWriteEncodeError.None;
case DriverDataType.Int64:
metric.Datatype = (uint)TahuDataType.Int64;
metric.LongValue = unchecked((ulong)Convert.ToInt64(value, CultureInfo.InvariantCulture));
return MqttWriteEncodeError.None;
case DriverDataType.UInt16:
metric.Datatype = (uint)TahuDataType.Uint16;
metric.IntValue = Convert.ToUInt16(value, CultureInfo.InvariantCulture);
return MqttWriteEncodeError.None;
case DriverDataType.UInt32:
metric.Datatype = (uint)TahuDataType.Uint32;
metric.IntValue = Convert.ToUInt32(value, CultureInfo.InvariantCulture);
return MqttWriteEncodeError.None;
case DriverDataType.UInt64:
metric.Datatype = (uint)TahuDataType.Uint64;
metric.LongValue = Convert.ToUInt64(value, CultureInfo.InvariantCulture);
return MqttWriteEncodeError.None;
case DriverDataType.Float32:
metric.Datatype = (uint)TahuDataType.Float;
metric.FloatValue = Convert.ToSingle(value, CultureInfo.InvariantCulture);
return MqttWriteEncodeError.None;
case DriverDataType.Float64:
metric.Datatype = (uint)TahuDataType.Double;
metric.DoubleValue = Convert.ToDouble(value, CultureInfo.InvariantCulture);
return MqttWriteEncodeError.None;
case DriverDataType.String:
metric.Datatype = (uint)TahuDataType.String;
metric.StringValue = value as string ?? Convert.ToString(value, CultureInfo.InvariantCulture) ?? "";
return MqttWriteEncodeError.None;
case DriverDataType.DateTime:
metric.Datatype = (uint)TahuDataType.DateTime;
metric.LongValue = (ulong)new DateTimeOffset(
Convert.ToDateTime(value, CultureInfo.InvariantCulture).ToUniversalTime())
.ToUnixTimeMilliseconds();
return MqttWriteEncodeError.None;
default:
// DriverDataType.Reference and anything added later: refuse rather than guess a
// wire representation. An unsupported type must not become a String on the wire.
return MqttWriteEncodeError.UnsupportedDataType;
}
}
catch (Exception ex) when (ex is InvalidCastException or FormatException or OverflowException)
{
return MqttWriteEncodeError.ValueNotRepresentable;
}
}
}
/// <summary>One encoded outbound write: where it goes and what it carries.</summary>
/// <param name="Topic">The concrete topic to publish on.</param>
/// <param name="Payload">The encoded message body.</param>
public readonly record struct MqttEncodedWrite(string Topic, byte[] Payload);
@@ -63,8 +63,31 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
/// </para>
/// </remarks>
public sealed class MqttDriver
: IDriver, ITagDiscovery, ISubscribable, IReadable, IHostConnectivityProbe, IRediscoverable, IAsyncDisposable, IDisposable
: IDriver, ITagDiscovery, ISubscribable, IReadable, IWritable, IHostConnectivityProbe, IRediscoverable,
IAsyncDisposable, IDisposable
{
// ---- Write status codes (Gitea #508) ----
//
// NAMED constants, never inline literals: StatusCodeParityTests checks each against the pinned SDK
// by reflection and can only see a named const (Gitea #497/#519). The names must match
// Opc.Ua.StatusCodes members exactly.
/// <summary>The reference is not an authored tag of this driver's live ingest path.</summary>
private const uint StatusBadNodeIdUnknown = 0x80340000u;
/// <summary>
/// The tag declares no write destination — a Plain tag with no <c>writeTopic</c>, or a Sparkplug
/// tag with an incomplete binding tuple. The honest answer to "can I write this?", and the whole
/// reason the encoder refuses rather than no-opping.
/// </summary>
private const uint StatusBadNotWritable = 0x803B0000u;
/// <summary>The supplied value cannot be represented in the tag's declared data type.</summary>
private const uint StatusBadTypeMismatch = 0x80740000u;
/// <summary>The publish failed, or the driver has no live broker connection to publish through.</summary>
private const uint StatusBadCommunicationError = 0x80050000u;
/// <summary>
/// Rough per-tag driver-attributable cost: the parsed <see cref="MqttTagDefinition"/> (five
/// strings — RawPath, topic, JSONPath and the record header) plus its cached
@@ -480,11 +503,16 @@ public sealed class MqttDriver
DriverDataType: ResolveDiscoveredDataType(def),
IsArray: false,
ArrayDim: null,
// MQTT ingest is one-way in P1 — this driver implements no IWritable leg, so every
// node is ViewOnly rather than an Operate node whose writes would silently vanish.
SecurityClass: SecurityClassification.ViewOnly,
// Gitea #508: MQTT now has a write leg, so a tag that declares a write destination is
// an Operate node. One that does not stays ViewOnly — the point of the distinction is
// that a node must never look writable while its writes would go nowhere.
SecurityClass: CanWrite(def) ? SecurityClassification.Operate : SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
// Default-off, deliberately: MQTT gives no application-level write acknowledgement, and
// a Sparkplug command is not necessarily idempotent (a pulse, a recipe step, a counter
// increment). The resilience pipeline must not replay one by default; an operator opts
// in per tag via Tag.WriteIdempotent.
WriteIdempotent: false));
}
@@ -585,6 +613,142 @@ public sealed class MqttDriver
return Task.FromResult<IReadOnlyList<DataValueSnapshot>>(results);
}
// ---- IWritable (Gitea #508) ----
/// <summary>
/// Whether <paramref name="def"/> declares somewhere for a write to go: a <c>writeTopic</c> in
/// Plain mode, or a complete command tuple in Sparkplug mode.
/// </summary>
/// <param name="def">The resolved tag definition.</param>
/// <returns><see langword="true"/> when a write to this tag can be addressed.</returns>
/// <remarks>
/// Asked of the SAME encoder the write path uses, so the browse-time answer and the runtime
/// answer cannot drift — a node advertised Operate that then refuses every write would be the
/// silent-no-op failure in a new costume. The value is irrelevant here, so a probe value of the
/// tag's own type is encoded and thrown away.
/// </remarks>
private bool CanWrite(MqttTagDefinition def)
=> MqttWriteEncoder.TryEncode(_options.Mode, def, ProbeValueFor(def.DataType), out _)
!= MqttWriteEncodeError.NoWriteDestination;
/// <summary>A never-published sentinel value of the given type, for the <see cref="CanWrite"/> probe.</summary>
/// <param name="dataType">The tag's declared data type.</param>
/// <returns>A representable value, or a string for types with no natural zero.</returns>
private static object ProbeValueFor(DriverDataType dataType) => dataType switch
{
DriverDataType.Boolean => false,
DriverDataType.DateTime => DateTime.UnixEpoch,
DriverDataType.String or DriverDataType.Reference => "",
_ => 0,
};
/// <summary>
/// Publishes a batch of writes: one MQTT message per request, to the tag's write topic (Plain)
/// or as an NCMD/DCMD command (Sparkplug B).
/// </summary>
/// <param name="writes">The requested writes.</param>
/// <param name="cancellationToken">Cancellation for the batch.</param>
/// <returns>One result per request, in request order.</returns>
/// <remarks>
/// <para>
/// <b>Never throws, and one bad request never fails the batch.</b> Each slot carries its own
/// status, matching <see cref="ReadAsync"/>: failing the whole call would let a single stale
/// reference reject every other write in the same OPC UA request.
/// </para>
/// <para>
/// <b>Good means "published", not "applied".</b> MQTT has no application-level write
/// acknowledgement, so a successful PUBLISH is the strongest truth available at this seam —
/// the same optimistic-Good posture Galaxy's fire-and-forget write takes. Device-level truth
/// arrives afterwards, as the echoed value on the tag's read topic, and corrects the node
/// through the ordinary ingest path. A write whose PUBLISH itself fails is reported Bad: the
/// optimism covers what the device did with the command, never whether it was sent.
/// </para>
/// <para>
/// <b>Consequence, inherited knowingly (see #5, write-outcome self-correction):</b> because the
/// transport cannot report device-level rejection, this driver can never surface a failed
/// <em>device</em> write — exactly the property Galaxy has. A device that silently ignores a
/// command leaves the node reading whatever it last published, which is the honest picture; a
/// device that has no echo topic at all leaves the operator with no confirmation, which is a
/// property of the plant's topic design rather than of this code.
/// </para>
/// </remarks>
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(writes);
var results = new WriteResult[writes.Count];
var connection = _connection;
for (var i = 0; i < writes.Count; i++)
{
var w = writes[i];
if (!TryResolve(w.FullReference, out var def))
{
results[i] = new WriteResult(StatusBadNodeIdUnknown);
continue;
}
var error = MqttWriteEncoder.TryEncode(_options.Mode, def, w.Value, out var encoded);
if (error != MqttWriteEncodeError.None)
{
results[i] = new WriteResult(MapEncodeError(error));
continue;
}
if (connection is null)
{
// Not yet connected, or torn down mid-rebuild. Reported rather than queued: a write
// buffered now and flushed at an unbounded later moment is the replay hazard
// WriteIdempotent exists to prevent, decided by the driver instead of the operator.
results[i] = new WriteResult(StatusBadCommunicationError);
continue;
}
try
{
await connection
.PublishAsync(encoded.Topic, encoded.Payload, MqttWriteEncoder.WriteQos, retain: false, cancellationToken)
.ConfigureAwait(false);
results[i] = new WriteResult(0u);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Caller-cancelled: fill the remainder rather than throwing, so the batch shape holds.
for (var j = i; j < writes.Count; j++)
{
results[j] = new WriteResult(StatusBadCommunicationError);
}
return results;
}
catch (Exception ex)
{
_logger?.LogWarning(
ex,
"MQTT driver '{DriverId}': write to '{Reference}' could not be published on '{Topic}'.",
_driverInstanceId,
w.FullReference,
encoded.Topic);
results[i] = new WriteResult(StatusBadCommunicationError);
}
}
return results;
}
/// <summary>Maps an encode refusal to the status an OPC UA client sees.</summary>
/// <param name="error">The encode outcome.</param>
/// <returns>The OPC UA status code.</returns>
private static uint MapEncodeError(MqttWriteEncodeError error) => error switch
{
MqttWriteEncodeError.NoWriteDestination => StatusBadNotWritable,
MqttWriteEncodeError.UnsupportedDataType => StatusBadNotWritable,
_ => StatusBadTypeMismatch,
};
// ---- IHostConnectivityProbe ----
/// <summary>The single broker this driver instance talks to, as <c>host:port</c>.</summary>
@@ -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;
@@ -0,0 +1,387 @@
using System.Buffers;
using System.Collections.Concurrent;
using System.Text;
using MQTTnet;
using Org.Eclipse.Tahu.Protobuf;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using TahuDataType = Org.Eclipse.Tahu.Protobuf.DataType;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests;
/// <summary>
/// Live validation of MQTT write-through (Gitea #508) against the real Mosquitto fixture: a Plain
/// publish to the tag's write topic, and Sparkplug NCMD/DCMD commands, observed on the broker by an
/// independent subscriber.
/// </summary>
/// <remarks>
/// <para>
/// <b>Observed on the broker, not on a fake.</b> The whole risk this feature carries is a write that
/// reports Good and reaches nothing, so the assertion has to be that a real message arrived on a
/// real broker at the topic the operator authored. A test double asserting "publish was called"
/// could not distinguish the feature working from the feature publishing to the wrong topic.
/// </para>
/// <para>
/// <b>Env-gated + skip-clean</b>, like every suite here: without <c>MQTT_FIXTURE_ENDPOINT</c> and
/// credentials each test calls <c>Assert.Skip</c>. See <see cref="MqttFixture"/>.
/// </para>
/// </remarks>
[Collection(MqttFixtureCollection.Name)]
[Trait("Category", "LiveIntegration")]
public sealed class MqttWriteLiveTests(MqttFixture fixture)
{
private const uint Good = 0x00000000u;
private const uint BadNotWritable = 0x803B0000u;
private static readonly TimeSpan MessageTimeout = TimeSpan.FromSeconds(30);
private readonly MqttFixture _fx = fixture;
private void SkipIfUnconfigured() => Assert.SkipWhen(_fx.SkipReason is not null, _fx.SkipReason ?? "");
// -----------------------------------------------------------------------
// Plain
// -----------------------------------------------------------------------
[Fact]
public async Task Plain_write_publishes_the_value_on_the_authored_write_topic()
{
SkipIfUnconfigured();
var ct = TestContext.Current.CancellationToken;
const string rawPath = "Fixture/Mqtt/Line1/Setpoint";
var readTopic = _fx.Topic($"write/{Guid.NewGuid():N}/state");
var writeTopic = _fx.Topic($"write/{Guid.NewGuid():N}/set");
await using var watcher = await TopicWatcher.StartAsync(_fx, writeTopic, ct);
var tag = new RawTagEntry(
rawPath,
$$"""{"topic":"{{readTopic}}","writeTopic":"{{writeTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""",
WriteIdempotent: false);
await using var driver = new MqttDriver(PlainOptions(tag), "mqtt-live-write");
await driver.InitializeAsync(string.Empty, ct);
var results = await driver.WriteAsync([new WriteRequest(rawPath, 42.5)], ct);
results.ShouldHaveSingleItem().StatusCode.ShouldBe(Good);
var payload = await watcher.WaitForOneAsync(MessageTimeout, ct);
Encoding.UTF8.GetString(payload).ShouldBe(
"42.5",
"the write must arrive on the authored write topic carrying the value, in invariant form");
}
[Fact]
public async Task Plain_write_never_lands_on_the_tags_read_topic()
{
SkipIfUnconfigured();
var ct = TestContext.Current.CancellationToken;
const string rawPath = "Fixture/Mqtt/Line1/SetpointSplit";
var suffix = Guid.NewGuid().ToString("N");
var readTopic = _fx.Topic($"write/{suffix}/state");
var writeTopic = _fx.Topic($"write/{suffix}/set");
// Both watchers are live BEFORE the write, so "nothing on the read topic" is a real observation
// over the same window rather than an absence that was never looked for.
await using var readWatcher = await TopicWatcher.StartAsync(_fx, readTopic, ct);
await using var writeWatcher = await TopicWatcher.StartAsync(_fx, writeTopic, ct);
var tag = new RawTagEntry(
rawPath,
$$"""{"topic":"{{readTopic}}","writeTopic":"{{writeTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""",
WriteIdempotent: false);
await using var driver = new MqttDriver(PlainOptions(tag), "mqtt-live-write-split");
await driver.InitializeAsync(string.Empty, ct);
(await driver.WriteAsync([new WriteRequest(rawPath, 7.25)], ct))[0].StatusCode.ShouldBe(Good);
// Positive control first: once the write HAS been seen, the read topic has had its chance.
await writeWatcher.WaitForOneAsync(MessageTimeout, ct);
readWatcher.Count.ShouldBe(
0,
"a write echoed onto the tag's own read topic would read back as a confirmed device value");
}
[Fact]
public async Task Plain_write_to_a_tag_with_no_write_topic_is_refused_end_to_end()
{
SkipIfUnconfigured();
var ct = TestContext.Current.CancellationToken;
const string rawPath = "Fixture/Mqtt/Line1/ReadOnly";
var tag = new RawTagEntry(
rawPath,
$$"""{"topic":"{{_fx.Topic("line1/temperature")}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64"}""",
WriteIdempotent: false);
await using var driver = new MqttDriver(PlainOptions(tag), "mqtt-live-write-readonly");
await driver.InitializeAsync(string.Empty, ct);
// Connected and resolvable — so BadNotWritable here is specifically "you never said where",
// not a connection failure wearing the same status.
(await driver.WriteAsync([new WriteRequest(rawPath, 1.0)], ct))[0].StatusCode.ShouldBe(BadNotWritable);
}
// -----------------------------------------------------------------------
// Sparkplug B
// -----------------------------------------------------------------------
[Fact]
public async Task Sparkplug_write_to_a_device_metric_publishes_a_DCMD()
{
SkipIfUnconfigured();
var ct = TestContext.Current.CancellationToken;
var group = $"WriteGate{Guid.NewGuid():N}"[..20];
const string node = "EdgeW";
const string device = "Filler1";
const string metric = "Setpoint";
const string rawPath = "Fixture/Mqtt/Sp/Setpoint";
var topic = $"spBv1.0/{group}/DCMD/{node}/{device}";
await using var watcher = await TopicWatcher.StartAsync(_fx, topic, ct);
var tag = new RawTagEntry(
rawPath,
$$"""{"groupId":"{{group}}","edgeNodeId":"{{node}}","deviceId":"{{device}}","metricName":"{{metric}}","dataType":"Float64"}""",
WriteIdempotent: false);
await using var driver = new MqttDriver(SparkplugOptions(group, tag), "mqtt-live-write-dcmd");
await driver.InitializeAsync(string.Empty, ct);
(await driver.WriteAsync([new WriteRequest(rawPath, 12.5)], ct))[0].StatusCode.ShouldBe(Good);
var bytes = await watcher.WaitForOneAsync(MessageTimeout, ct);
var payload = Payload.Parser.ParseFrom(bytes);
payload.Metrics.ShouldHaveSingleItem();
payload.Metrics[0].Name.ShouldBe(metric, "a command must address the metric by NAME, never by alias");
payload.Metrics[0].Datatype.ShouldBe((uint)TahuDataType.Double);
payload.Metrics[0].DoubleValue.ShouldBe(12.5);
payload.HasSeq.ShouldBeFalse("seq belongs to the edge node's own outbound stream, not to a command");
}
[Fact]
public async Task Sparkplug_write_to_an_edge_node_metric_publishes_an_NCMD()
{
SkipIfUnconfigured();
var ct = TestContext.Current.CancellationToken;
var group = $"WriteGate{Guid.NewGuid():N}"[..20];
const string node = "EdgeW";
const string metric = "NodeSetpoint";
const string rawPath = "Fixture/Mqtt/Sp/NodeSetpoint";
await using var watcher = await TopicWatcher.StartAsync(_fx, $"spBv1.0/{group}/NCMD/{node}", ct);
var tag = new RawTagEntry(
rawPath,
$$"""{"groupId":"{{group}}","edgeNodeId":"{{node}}","metricName":"{{metric}}","dataType":"Int32"}""",
WriteIdempotent: false);
await using var driver = new MqttDriver(SparkplugOptions(group, tag), "mqtt-live-write-ncmd");
await driver.InitializeAsync(string.Empty, ct);
(await driver.WriteAsync([new WriteRequest(rawPath, 7)], ct))[0].StatusCode.ShouldBe(Good);
// NCMD is a SHARED channel: the driver publishes its own rebirth request
// ("Node Control/Rebirth") on this exact topic when it connects, and the live gate caught it
// arriving first. Wait for the command carrying OUR metric rather than for the first NCMD —
// the DCMD case has no such contention, which is why only this one needed it.
var payload = Payload.Parser.ParseFrom(
await watcher.WaitForMatchAsync(
bytes => Payload.Parser.ParseFrom(bytes).Metrics.Any(m => m.Name == metric),
MessageTimeout,
ct));
var written = payload.Metrics.ShouldHaveSingleItem();
written.Name.ShouldBe(metric);
written.Datatype.ShouldBe((uint)TahuDataType.Int32);
written.IntValue.ShouldBe(7u);
}
[Fact]
public async Task A_sparkplug_command_is_not_retained()
{
SkipIfUnconfigured();
var ct = TestContext.Current.CancellationToken;
var group = $"WriteGate{Guid.NewGuid():N}"[..20];
const string node = "EdgeW";
const string rawPath = "Fixture/Mqtt/Sp/Retain";
var topic = $"spBv1.0/{group}/NCMD/{node}";
await using var firstWatcher = await TopicWatcher.StartAsync(_fx, topic, ct);
var tag = new RawTagEntry(
rawPath,
$$"""{"groupId":"{{group}}","edgeNodeId":"{{node}}","metricName":"Cmd","dataType":"Boolean"}""",
WriteIdempotent: false);
await using var driver = new MqttDriver(SparkplugOptions(group, tag), "mqtt-live-write-retain");
await driver.InitializeAsync(string.Empty, ct);
(await driver.WriteAsync([new WriteRequest(rawPath, true)], ct))[0].StatusCode.ShouldBe(Good);
await firstWatcher.WaitForOneAsync(MessageTimeout, ct);
// A retained command would be replayed by the broker to every LATER subscriber — including the
// device's own reconnect — so a single stale write would be re-applied forever. Subscribing
// fresh, after the publish, is the only way to observe that.
await using var lateWatcher = await TopicWatcher.StartAsync(_fx, topic, ct);
await Task.Delay(TimeSpan.FromSeconds(2), ct);
// Counting OUR command only. The driver's own rebirth NCMD shares this topic, so a bare count
// would make this assertion depend on rebirth timing rather than on the retain flag.
lateWatcher.CountMatching(bytes => Payload.Parser.ParseFrom(bytes).Metrics.Any(m => m.Name == "Cmd"))
.ShouldBe(0, "a retained NCMD/DCMD would drive a device into a permanent command loop");
}
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
private MqttDriverOptions PlainOptions(params RawTagEntry[] rawTags) => new()
{
Host = _fx.PlainHost,
Port = _fx.PlainPort,
UseTls = false,
Username = _fx.Username,
Password = _fx.Password,
ClientId = $"otopcua-live-{Guid.NewGuid():N}",
ConnectTimeoutSeconds = 15,
Mode = MqttMode.Plain,
Plain = new MqttPlainOptions { DefaultQos = 1 },
RawTags = rawTags,
};
private MqttDriverOptions SparkplugOptions(string groupId, params RawTagEntry[] rawTags) => new()
{
Host = _fx.PlainHost,
Port = _fx.PlainPort,
UseTls = false,
Username = _fx.Username,
Password = _fx.Password,
ClientId = $"otopcua-live-{Guid.NewGuid():N}",
ConnectTimeoutSeconds = 15,
Mode = MqttMode.SparkplugB,
Sparkplug = new MqttSparkplugOptions { GroupId = groupId },
RawTags = rawTags,
};
/// <summary>
/// An independent broker subscriber that records the payloads seen on one concrete topic — the
/// third party that makes "the write actually reached the broker" an observation rather than an
/// assumption about the driver's own code.
/// </summary>
private sealed class TopicWatcher : IAsyncDisposable
{
private readonly IMqttClient _client;
private readonly ConcurrentQueue<byte[]> _payloads = new();
private TopicWatcher(IMqttClient client) => _client = client;
/// <summary>How many messages have been observed on the watched topic.</summary>
public int Count => _payloads.Count;
public static async Task<TopicWatcher> StartAsync(MqttFixture fixture, string topic, CancellationToken ct)
{
var client = new MqttClientFactory().CreateMqttClient();
var watcher = new TopicWatcher(client);
client.ApplicationMessageReceivedAsync += args =>
{
watcher._payloads.Enqueue(args.ApplicationMessage.Payload.ToArray());
return Task.CompletedTask;
};
var options = new MqttClientOptionsBuilder()
.WithTcpServer(fixture.PlainHost, fixture.PlainPort)
.WithCredentials(fixture.Username, fixture.Password)
.WithClientId($"otopcua-live-watch-{Guid.NewGuid():N}")
.WithTimeout(TimeSpan.FromSeconds(10))
.Build();
await client.ConnectAsync(options, ct).ConfigureAwait(false);
await client.SubscribeAsync(
new MqttClientSubscribeOptionsBuilder().WithTopicFilter(f => f.WithTopic(topic)).Build(),
ct).ConfigureAwait(false);
return watcher;
}
/// <summary>How many observed messages satisfy <paramref name="predicate"/>.</summary>
/// <param name="predicate">The payload test.</param>
/// <returns>The matching count.</returns>
public int CountMatching(Func<byte[], bool> predicate) => _payloads.Count(p => Safe(predicate, p));
/// <summary>
/// Waits for a message satisfying <paramref name="predicate"/>. Needed where the topic is
/// shared — an NCMD topic also carries the driver's own rebirth requests.
/// </summary>
/// <param name="predicate">The payload test.</param>
/// <param name="timeout">Bound on the wait.</param>
/// <param name="ct">Caller cancellation.</param>
/// <returns>The first matching payload.</returns>
public async Task<byte[]> WaitForMatchAsync(Func<byte[], bool> predicate, TimeSpan timeout, CancellationToken ct)
{
var deadline = DateTime.UtcNow + timeout;
while (true)
{
if (_payloads.FirstOrDefault(p => Safe(predicate, p)) is { } match)
{
return match;
}
if (DateTime.UtcNow >= deadline)
{
throw new TimeoutException(
$"No matching message arrived on the watched topic within {timeout} " +
$"({_payloads.Count} message(s) seen, none matching).");
}
await Task.Delay(100, ct).ConfigureAwait(false);
}
}
/// <summary>Applies a payload predicate, treating an undecodable payload as "no match".</summary>
/// <param name="predicate">The payload test.</param>
/// <param name="payload">The observed bytes.</param>
/// <returns><see langword="true"/> when the payload matches.</returns>
private static bool Safe(Func<byte[], bool> predicate, byte[] payload)
{
try { return predicate(payload); }
catch { return false; }
}
public async Task<byte[]> WaitForOneAsync(TimeSpan timeout, CancellationToken ct)
{
var deadline = DateTime.UtcNow + timeout;
while (!_payloads.TryPeek(out _))
{
if (DateTime.UtcNow >= deadline)
{
throw new TimeoutException(
$"No message arrived on the watched topic within {timeout}. The write reported success " +
"but nothing reached the broker — the exact failure this gate exists to catch.");
}
await Task.Delay(100, ct).ConfigureAwait(false);
}
_payloads.TryDequeue(out var payload);
return payload!;
}
public async ValueTask DisposeAsync()
{
try { await _client.DisconnectAsync(cancellationToken: default).ConfigureAwait(false); }
catch { /* teardown must not mask a test failure */ }
_client.Dispose();
}
}
}
@@ -0,0 +1,160 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// Unit tests for <see cref="MqttDriver"/>'s <see cref="IWritable"/> leg (Gitea #508) — the batch
/// contract and the per-slot refusals, up to (not including) the broker publish.
/// </summary>
/// <remarks>
/// The publish itself is the live gate's job: it needs a broker, and asserting it here would only
/// prove that a fake was called. What IS worth pinning offline is everything that decides whether a
/// write should reach a broker at all — because that is where a silent no-op would hide.
/// </remarks>
[Trait("Category", "Unit")]
public sealed class MqttDriverWriteTests
{
private const string Path = "Plant/Mqtt/dev1/Setpoint";
private const string ReadOnlyPath = "Plant/Mqtt/dev1/Temp";
private static RawTagEntry PlainTag(string rawPath, string? writeTopic)
{
var writeKey = writeTopic is null ? "" : $$""","writeTopic":"{{writeTopic}}" """;
return new RawTagEntry(
rawPath,
$$"""{"topic":"factory/oven/temp","payloadFormat":"Scalar","dataType":"Float64"{{writeKey}}}""",
WriteIdempotent: false);
}
private static MqttDriver PlainDriver(params RawTagEntry[] tags)
=> new(new MqttDriverOptions { Mode = MqttMode.Plain, RawTags = tags }, "d", null);
[Fact]
public void MqttDriver_implements_IWritable()
// The interface's presence is the feature: without it every MQTT node materialises read-only,
// regardless of what the operator authored on the tag.
=> PlainDriver().ShouldBeAssignableTo<IWritable>();
[Fact]
public async Task An_unknown_reference_is_refused_in_its_own_slot()
{
var results = await PlainDriver(PlainTag(Path, "oven/set"))
.WriteAsync([new WriteRequest("Plant/Mqtt/dev1/NotATag", 1.0)], default);
results.Count.ShouldBe(1);
results[0].StatusCode.ShouldBe(0x80340000u); // BadNodeIdUnknown
}
[Fact]
public async Task A_tag_with_no_write_topic_is_refused_as_not_writable()
{
// NOT BadCommunicationError and NOT Good: the tag is addressable and the broker is irrelevant —
// the operator simply never said where a write should go. The status has to say that, or the
// only diagnosis available is "writes don't work".
var results = await PlainDriver(PlainTag(ReadOnlyPath, writeTopic: null))
.WriteAsync([new WriteRequest(ReadOnlyPath, 1.0)], default);
results[0].StatusCode.ShouldBe(0x803B0000u); // BadNotWritable
}
[Fact]
public async Task A_writable_tag_on_a_disconnected_driver_reports_a_communication_failure()
{
// Reported, never queued. A write buffered now and flushed at an unbounded later moment is the
// replay hazard WriteIdempotent exists to prevent — and this would be the driver deciding it on
// the operator's behalf.
var results = await PlainDriver(PlainTag(Path, "oven/set"))
.WriteAsync([new WriteRequest(Path, 42.5)], default);
results[0].StatusCode.ShouldBe(0x80050000u); // BadCommunicationError
}
[Fact]
public async Task One_bad_request_does_not_fail_the_others_in_the_batch()
{
// Mirrors ReadAsync: failing the whole call would let one stale reference reject every other
// write in the same OPC UA request.
var driver = PlainDriver(PlainTag(Path, "oven/set"), PlainTag(ReadOnlyPath, writeTopic: null));
var results = await driver.WriteAsync(
[
new WriteRequest("Plant/Mqtt/dev1/NotATag", 1.0),
new WriteRequest(ReadOnlyPath, 1.0),
new WriteRequest(Path, 42.5),
],
default);
// Order-preserving, one status per request, each naming its own distinct cause.
results.Count.ShouldBe(3);
results[0].StatusCode.ShouldBe(0x80340000u); // BadNodeIdUnknown
results[1].StatusCode.ShouldBe(0x803B0000u); // BadNotWritable
results[2].StatusCode.ShouldBe(0x80050000u); // BadCommunicationError — resolved and encodable
}
[Fact]
public async Task An_empty_batch_is_answered_with_an_empty_result()
=> (await PlainDriver().WriteAsync([], default)).ShouldBeEmpty();
[Fact]
public async Task WriteAsync_rejects_a_null_batch()
=> await Should.ThrowAsync<ArgumentNullException>(
async () => await PlainDriver().WriteAsync(null!, default));
// ---- Discovery advertises writability consistently with the write path ------------------------
[Fact]
public async Task A_tag_with_a_write_topic_is_discovered_as_an_operate_node()
{
var builder = new WriteCapturingBuilder();
await PlainDriver(PlainTag(Path, "oven/set")).DiscoverAsync(builder, default);
builder.Variables.ShouldHaveSingleItem();
builder.Variables[0].SecurityClass.ShouldBe(SecurityClassification.Operate);
// Default-off regardless: MQTT gives no application-level write ack, and a Sparkplug command is
// not necessarily idempotent. Opting in is the operator's call, per tag.
builder.Variables[0].WriteIdempotent.ShouldBeFalse();
}
[Fact]
public async Task A_tag_without_a_write_topic_stays_view_only()
{
// The browse-time answer must match the runtime one — a node advertised Operate that then
// refuses every write would be the silent-no-op failure in a new costume.
var builder = new WriteCapturingBuilder();
await PlainDriver(PlainTag(ReadOnlyPath, writeTopic: null)).DiscoverAsync(builder, default);
builder.Variables[0].SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
}
/// <summary>Minimal capturing builder — this project does not reference Commons' own.</summary>
private sealed class WriteCapturingBuilder : IAddressSpaceBuilder
{
public List<DriverAttributeInfo> Variables { get; } = [];
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
Variables.Add(attributeInfo);
return new Handle(attributeInfo.FullName);
}
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args) { }
}
}
}
@@ -0,0 +1,251 @@
using System.Text;
using Google.Protobuf;
using Org.Eclipse.Tahu.Protobuf;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
using TahuDataType = Org.Eclipse.Tahu.Protobuf.DataType;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// Unit tests for <see cref="MqttWriteEncoder"/> — the pure half of MQTT write-through (Gitea #508):
/// what a write encodes to, and which writes are refused before anything reaches a broker.
/// </summary>
[Trait("Category", "Unit")]
public sealed class MqttWriteEncoderTests
{
private static MqttTagDefinition Plain(
string? writeTopic,
MqttPayloadFormat format = MqttPayloadFormat.Scalar,
DriverDataType dataType = DriverDataType.Float64)
=> new(
Name: "Plant/Mqtt/dev1/Setpoint",
Topic: "factory/oven/temp",
PayloadFormat: format,
JsonPath: "$",
DataType: dataType,
Qos: null,
RetainSeed: true,
WriteTopic: writeTopic);
private static MqttTagDefinition Sparkplug(
string? deviceId,
DriverDataType dataType = DriverDataType.Float64,
string metricName = "Speed")
=> new(
Name: "Plant/Mqtt/dev1/Speed",
Topic: "",
PayloadFormat: MqttPayloadFormat.Json,
JsonPath: "$",
DataType: dataType,
Qos: null,
RetainSeed: true,
GroupId: "OtOpcUaSim",
EdgeNodeId: "EdgeA",
DeviceId: deviceId,
MetricName: metricName);
// ---- The refusal that is the whole point of the feature -------------------------------------
[Fact]
public void A_plain_tag_with_no_write_topic_is_refused_rather_than_published_nowhere()
{
// Before #508 there was no IWritable at all — honest read-only. The one outcome that would be
// WORSE is a node that accepts a write, reports Good and publishes nothing. So this must be a
// refusal, and specifically the "no destination" one, which the driver maps to BadNotWritable.
var error = MqttWriteEncoder.TryEncode(MqttMode.Plain, Plain(writeTopic: null), 42.5, out var encoded);
error.ShouldBe(MqttWriteEncodeError.NoWriteDestination);
encoded.Topic.ShouldBeNull();
encoded.Payload.ShouldBeNull();
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void A_blank_write_topic_is_treated_as_absent(string writeTopic)
=> MqttWriteEncoder.TryEncode(MqttMode.Plain, Plain(writeTopic), 1, out _)
.ShouldBe(MqttWriteEncodeError.NoWriteDestination);
[Fact]
public void A_sparkplug_tag_with_an_incomplete_tuple_has_no_command_address()
{
// A Sparkplug tag's command address IS its binding tuple, so an incomplete one is the same
// failure as a missing write topic, not a different kind.
var def = Sparkplug(deviceId: null) with { MetricName = null };
MqttWriteEncoder.TryEncode(MqttMode.SparkplugB, def, 1.0, out _)
.ShouldBe(MqttWriteEncodeError.NoWriteDestination);
}
// ---- Plain encoding --------------------------------------------------------------------------
[Fact]
public void Plain_publishes_to_the_write_topic_not_the_read_topic()
{
// The two-field split is the point: publishing a command onto the tag's own read topic would
// echo back and read as a confirmed device value.
var def = Plain("factory/oven/temp/set");
MqttWriteEncoder.TryEncode(MqttMode.Plain, def, 42.5, out var encoded)
.ShouldBe(MqttWriteEncodeError.None);
encoded.Topic.ShouldBe("factory/oven/temp/set");
encoded.Topic.ShouldNotBe(def.Topic);
}
[Theory]
[InlineData(MqttPayloadFormat.Scalar)]
[InlineData(MqttPayloadFormat.Raw)]
public void Scalar_and_raw_encode_the_value_as_utf8_text(MqttPayloadFormat format)
{
MqttWriteEncoder.TryEncode(MqttMode.Plain, Plain("t/set", format), 42.5, out var encoded)
.ShouldBe(MqttWriteEncodeError.None);
Encoding.UTF8.GetString(encoded.Payload).ShouldBe("42.5");
}
[Fact]
public void Numbers_are_encoded_with_the_invariant_culture()
{
// A comma decimal separator would publish a number the device cannot parse — and it would only
// reproduce on a host with a European locale, which is the worst kind of bug to chase.
var previous = Thread.CurrentThread.CurrentCulture;
Thread.CurrentThread.CurrentCulture = new System.Globalization.CultureInfo("de-DE");
try
{
MqttWriteEncoder.TryEncode(MqttMode.Plain, Plain("t/set"), 42.5, out var encoded)
.ShouldBe(MqttWriteEncodeError.None);
Encoding.UTF8.GetString(encoded.Payload).ShouldBe("42.5");
}
finally
{
Thread.CurrentThread.CurrentCulture = previous;
}
}
[Fact]
public void Json_encodes_a_bare_json_literal()
{
MqttWriteEncoder.TryEncode(
MqttMode.Plain, Plain("t/set", MqttPayloadFormat.Json), 42.5, out var encoded)
.ShouldBe(MqttWriteEncodeError.None);
Encoding.UTF8.GetString(encoded.Payload).ShouldBe("42.5");
}
[Fact]
public void Json_quotes_a_string_value()
=> Encoding.UTF8.GetString(
EncodeOrThrow(MqttMode.Plain, Plain("t/set", MqttPayloadFormat.Json, DriverDataType.String), "on"))
.ShouldBe("\"on\"");
[Fact]
public void A_null_value_is_refused()
// A zero-length MQTT payload is a legitimate clear-the-retained-message instruction — an
// entirely different operation. Encoding null as "no bytes" would conflate the two.
=> MqttWriteEncoder.TryEncode(MqttMode.Plain, Plain("t/set"), null, out _)
.ShouldBe(MqttWriteEncodeError.ValueNotRepresentable);
// ---- Sparkplug command encoding ---------------------------------------------------------------
[Fact]
public void A_device_metric_encodes_as_a_DCMD_addressed_to_that_device()
{
// Addressing a device metric with NCMD would command the NODE, which does not own it.
MqttWriteEncoder.TryEncode(MqttMode.SparkplugB, Sparkplug("Filler1"), 12.5, out var encoded)
.ShouldBe(MqttWriteEncodeError.None);
encoded.Topic.ShouldBe("spBv1.0/OtOpcUaSim/DCMD/EdgeA/Filler1");
}
[Fact]
public void An_edge_node_metric_encodes_as_an_NCMD()
=> EncodeTopic(MqttMode.SparkplugB, Sparkplug(deviceId: null))
.ShouldBe("spBv1.0/OtOpcUaSim/NCMD/EdgeA");
[Fact]
public void The_command_carries_the_metric_by_name_with_an_explicit_datatype()
{
// By NAME, never by alias: an alias may be reused for a different metric across a rebirth.
// Datatype explicitly, because a node that treats it as ambiguous drops the command SILENTLY —
// the exact failure mode this feature must not have.
var bytes = EncodeOrThrow(MqttMode.SparkplugB, Sparkplug("Filler1"), 12.5);
var payload = Payload.Parser.ParseFrom(bytes);
payload.Metrics.Count.ShouldBe(1);
var metric = payload.Metrics[0];
metric.Name.ShouldBe("Speed");
metric.Alias.ShouldBe(0ul);
metric.Datatype.ShouldBe((uint)TahuDataType.Double);
metric.DoubleValue.ShouldBe(12.5);
metric.Timestamp.ShouldBeGreaterThan(0ul);
payload.Timestamp.ShouldBeGreaterThan(0ul);
}
[Fact]
public void The_command_carries_no_sequence_number()
{
// seq belongs to the messages an edge node itself publishes. A strict node validating a
// host-supplied one against its own counter would have grounds to reject the command.
var payload = Payload.Parser.ParseFrom(EncodeOrThrow(MqttMode.SparkplugB, Sparkplug("Filler1"), 1.0));
payload.HasSeq.ShouldBeFalse();
}
[Theory]
[InlineData(DriverDataType.Boolean, true, (uint)TahuDataType.Boolean)]
[InlineData(DriverDataType.Int32, 7, (uint)TahuDataType.Int32)]
[InlineData(DriverDataType.Int64, 7L, (uint)TahuDataType.Int64)]
[InlineData(DriverDataType.UInt32, 7, (uint)TahuDataType.Uint32)]
[InlineData(DriverDataType.Float32, 1.5f, (uint)TahuDataType.Float)]
[InlineData(DriverDataType.String, "x", (uint)TahuDataType.String)]
public void The_declared_data_type_selects_the_wire_datatype(
DriverDataType dataType, object value, uint expectedWireType)
{
var payload = Payload.Parser.ParseFrom(
EncodeOrThrow(MqttMode.SparkplugB, Sparkplug("Filler1", dataType), value));
payload.Metrics[0].Datatype.ShouldBe(expectedWireType);
}
[Fact]
public void An_unrepresentable_value_is_refused_rather_than_coerced()
// "not-a-number" into a Float64 tag must not silently become 0 on a live plant.
=> MqttWriteEncoder.TryEncode(MqttMode.SparkplugB, Sparkplug("Filler1"), "not-a-number", out _)
.ShouldBe(MqttWriteEncodeError.ValueNotRepresentable);
[Fact]
public void A_type_with_no_command_encoding_is_refused_rather_than_stringified()
// Reference has no Sparkplug wire representation. Falling back to String would put a value on
// the wire that is not what the tag's type says it is.
=> MqttWriteEncoder.TryEncode(
MqttMode.SparkplugB, Sparkplug("Filler1", DriverDataType.Reference), "ref", out _)
.ShouldBe(MqttWriteEncodeError.UnsupportedDataType);
[Fact]
public void The_mode_decides_the_shape_not_the_blob()
{
// A tag can legitimately carry both shapes' keys (the editor preserves unknown keys across a
// retype), so sniffing would make one authored tag mean two different things.
var both = Sparkplug("Filler1") with { WriteTopic = "plain/set" };
EncodeTopic(MqttMode.Plain, both).ShouldBe("plain/set");
EncodeTopic(MqttMode.SparkplugB, both).ShouldBe("spBv1.0/OtOpcUaSim/DCMD/EdgeA/Filler1");
}
private static string EncodeTopic(MqttMode mode, MqttTagDefinition def)
{
MqttWriteEncoder.TryEncode(mode, def, 1.0, out var encoded).ShouldBe(MqttWriteEncodeError.None);
return encoded.Topic;
}
private static byte[] EncodeOrThrow(MqttMode mode, MqttTagDefinition def, object value)
{
MqttWriteEncoder.TryEncode(mode, def, value, out var encoded).ShouldBe(MqttWriteEncodeError.None);
return encoded.Payload;
}
}