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:
@@ -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>
|
||||
|
||||
+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