7628402893
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
438 lines
22 KiB
C#
438 lines
22 KiB
C#
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 <b>usable</b> write destination: a Plain tag with no <c>writeTopic</c> or
|
|
/// one carrying an MQTT wildcard, or a Sparkplug tag whose binding tuple is incomplete or whose
|
|
/// group/edge-node/device id is not a legal topic segment (see <see cref="MqttAddressRules"/>).
|
|
/// 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 a Sparkplug NCMD/DCMD command. See the remarks on <see cref="TryEncode"/>. Plain-mode
|
|
/// writes use <see cref="PlainWriteQos"/> instead.
|
|
/// </summary>
|
|
public const int WriteQos = 0;
|
|
|
|
/// <summary>
|
|
/// QoS for a Plain-mode <c>writeTopic</c> publish (STAB-26). At QoS 1 the publish completes only
|
|
/// once the broker has PUBACKed (or refused) it, so a Good write status means "the broker accepted
|
|
/// the message" rather than "the bytes left this socket"; at QoS 0 a broker-side refusal or a
|
|
/// connection that dropped under the send was indistinguishable from success.
|
|
/// </summary>
|
|
public const int PlainWriteQos = 1;
|
|
|
|
/// <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>
|
|
/// <param name="effectiveDataType">
|
|
/// Sparkplug only (STAB-25): the type to encode the metric as when the tag did not author one — the
|
|
/// type the metric's live birth certificate declared, the same <c>authored ? authored : birth</c>
|
|
/// precedence the ingest path coerces reads with. <see langword="null"/> encodes as
|
|
/// <see cref="MqttTagDefinition.DataType"/>, which for an unauthored tag is only the record's
|
|
/// default and would command the metric with the wrong wire type. Ignored in Plain mode.
|
|
/// </param>
|
|
/// <param name="birthDataType">
|
|
/// Sparkplug only: the metric's OWN birth-declared <see cref="SparkplugDataType"/>, used when the tag
|
|
/// authored no type. Preferred over <paramref name="effectiveDataType"/> because the
|
|
/// <c>ToDriverDataType</c> map is lossy (Int8→Int16, Uint8→Uint16, Text/Uuid/Bytes/File→String,
|
|
/// <c>*Array</c>→scalar), so commanding through it sends the wrong Tahu datatype. A birth type the
|
|
/// encoder cannot represent (arrays, DataSet, Template, …) is REFUSED as
|
|
/// <see cref="MqttWriteEncodeError.UnsupportedDataType"/> — a wrong type is never sent.
|
|
/// </param>
|
|
/// <returns>The encode outcome.</returns>
|
|
/// <remarks>
|
|
/// <b>Retain <see langword="false"/> always; QoS 1 for Plain, 0 for Sparkplug commands.</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,
|
|
DriverDataType? effectiveDataType = null,
|
|
SparkplugDataType? birthDataType = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(def);
|
|
encoded = default;
|
|
|
|
return mode == MqttMode.SparkplugB
|
|
? TryEncodeSparkplug(def, value, effectiveDataType, birthDataType, 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;
|
|
}
|
|
|
|
// A wildcard topic is not a destination — MQTT-3.3.2-2 forbids one in a PUBLISH, and a
|
|
// conformant broker answers a protocol violation by CLOSING the connection, which is the same
|
|
// connection every one of this driver's subscriptions lives on. So publishing anyway would turn
|
|
// one mis-authored tag into a whole-driver outage. `NoWriteDestination` (→ BadNotWritable) is the
|
|
// right verdict rather than a new error: the tag genuinely has nowhere a write could be sent, and
|
|
// the read path is untouched. See MqttAddressRules.
|
|
if (MqttAddressRules.HasWildcard(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), PlainWriteQos);
|
|
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,
|
|
DriverDataType? effectiveDataType,
|
|
SparkplugDataType? birthDataType,
|
|
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;
|
|
}
|
|
|
|
// …and an id that is not a legal topic SEGMENT is the same failure by a different route: the
|
|
// tuple is assembled into the NCMD/DCMD topic, so a '/' would silently address a different node
|
|
// than the tag names while '+'/'#' would make the PUBLISH a protocol violation (see the Plain
|
|
// branch). `metricName` is exempt on purpose — it rides in the payload, and Sparkplug's own
|
|
// canonical names use '/'.
|
|
if (MqttAddressRules.IsIllegalTopicSegment(groupId)
|
|
|| MqttAddressRules.IsIllegalTopicSegment(edgeNodeId)
|
|
|| MqttAddressRules.IsIllegalTopicSegment(def.DeviceId))
|
|
{
|
|
return MqttWriteEncodeError.NoWriteDestination;
|
|
}
|
|
|
|
if (value is null)
|
|
{
|
|
return MqttWriteEncodeError.ValueNotRepresentable;
|
|
}
|
|
|
|
var metric = new Payload.Types.Metric { Name = metricName };
|
|
var error = !def.DataTypeAuthored && birthDataType is { } birth
|
|
? ApplyBirthValue(metric, birth, value)
|
|
: ApplyValue(metric, def.DataTypeAuthored ? def.DataType : effectiveDataType ?? 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(), WriteQos);
|
|
return MqttWriteEncodeError.None;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets the metric's value field and <c>Datatype</c> from the metric's own birth-declared Sparkplug
|
|
/// type, for the types whose driver-type mapping is lossy. Types with a 1:1 driver mapping delegate
|
|
/// to <see cref="ApplyValue"/>.
|
|
/// </summary>
|
|
/// <param name="metric">The metric being built.</param>
|
|
/// <param name="birth">The birth certificate's declared datatype.</param>
|
|
/// <param name="value">The value to convert.</param>
|
|
/// <returns>The encode outcome.</returns>
|
|
private static MqttWriteEncodeError ApplyBirthValue(Payload.Types.Metric metric, SparkplugDataType birth, object value)
|
|
{
|
|
try
|
|
{
|
|
switch (birth)
|
|
{
|
|
case TahuDataType.Int8:
|
|
metric.Datatype = (uint)TahuDataType.Int8;
|
|
metric.IntValue = unchecked((uint)Convert.ToSByte(value, CultureInfo.InvariantCulture));
|
|
return MqttWriteEncodeError.None;
|
|
|
|
case TahuDataType.Uint8:
|
|
metric.Datatype = (uint)TahuDataType.Uint8;
|
|
metric.IntValue = Convert.ToByte(value, CultureInfo.InvariantCulture);
|
|
return MqttWriteEncodeError.None;
|
|
|
|
case TahuDataType.Text:
|
|
case TahuDataType.Uuid:
|
|
metric.Datatype = (uint)birth;
|
|
metric.StringValue = value as string ?? Convert.ToString(value, CultureInfo.InvariantCulture) ?? "";
|
|
return MqttWriteEncodeError.None;
|
|
|
|
case TahuDataType.Bytes:
|
|
case TahuDataType.File:
|
|
if (value is not byte[] bytes)
|
|
{
|
|
return MqttWriteEncodeError.ValueNotRepresentable;
|
|
}
|
|
|
|
metric.Datatype = (uint)birth;
|
|
metric.BytesValue = ByteString.CopyFrom(bytes);
|
|
return MqttWriteEncodeError.None;
|
|
}
|
|
}
|
|
catch (Exception ex) when (ex is InvalidCastException or FormatException or OverflowException)
|
|
{
|
|
return MqttWriteEncodeError.ValueNotRepresentable;
|
|
}
|
|
|
|
// Arrays, DataSet, Template, PropertySet, Unknown: refused — never sent as a scalar of another type.
|
|
if (birth.IsSparkplugArray() || birth.ToDriverDataType() is null)
|
|
{
|
|
return MqttWriteEncodeError.UnsupportedDataType;
|
|
}
|
|
|
|
return ApplyValue(metric, birth.ToDriverDataType()!.Value, value);
|
|
}
|
|
|
|
/// <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:
|
|
{
|
|
var authored = Convert.ToDateTime(value, CultureInfo.InvariantCulture);
|
|
|
|
// An Unspecified kind is read as UTC, not passed to ToUniversalTime() — that treats
|
|
// it as LOCAL, so the identical authored instant would encode differently on the two
|
|
// nodes of a redundant pair, which are not required to share a timezone. Reading it
|
|
// as UTC rather than refusing it keeps an ordinary authored "2026-08-10T12:00:00"
|
|
// writable: Convert.ToDateTime produces exactly that kind for one.
|
|
var utc = authored.Kind == DateTimeKind.Unspecified
|
|
? DateTime.SpecifyKind(authored, DateTimeKind.Utc)
|
|
: authored.ToUniversalTime();
|
|
|
|
// Sparkplug's DateTime is an UNSIGNED milliseconds-since-epoch, so a pre-1970 instant
|
|
// has no representation at all — the cast below would wrap a negative long into a
|
|
// timestamp ~584 million years in the future. Refuse it: unrepresentable values are
|
|
// refused, never coerced.
|
|
var unixMs = new DateTimeOffset(utc).ToUnixTimeMilliseconds();
|
|
if (unixMs < 0) return MqttWriteEncodeError.ValueNotRepresentable;
|
|
|
|
metric.Datatype = (uint)TahuDataType.DateTime;
|
|
metric.LongValue = (ulong)unixMs;
|
|
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>
|
|
/// <param name="Qos">The MQTT QoS to publish at — see <see cref="MqttWriteEncoder.PlainWriteQos"/> / <see cref="MqttWriteEncoder.WriteQos"/>.</param>
|
|
public readonly record struct MqttEncodedWrite(string Topic, byte[] Payload, int Qos = MqttWriteEncoder.WriteQos);
|