using Google.Protobuf;
using Org.Eclipse.Tahu.Protobuf;
using TahuDataType = Org.Eclipse.Tahu.Protobuf.DataType;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
///
/// Decodes Sparkplug-B wire bytes into — the driver-side projection
/// the ingest state machine consumes. Decode only; the NCMD encode path lives in
/// RebirthRequester.
///
///
///
/// Nothing here throws, for any input. This runs on MQTTnet's shared dispatcher thread,
/// behind an unauthenticated firehose the plant's edge nodes publish into. An escaping
/// exception would not degrade one tag — it would stall or kill delivery for every
/// subscription on the connection. Garbage bytes, a truncated body, a zero-length payload, a
/// valid protobuf of some other schema and a pathologically nested Template all resolve to a
/// verdict: returns and
/// returns .
///
///
/// Zero-length input is invalid, not empty. Protobuf would happily parse zero bytes as
/// "a Payload with every field defaulted", but in Sparkplug an empty MQTT body is never a
/// legitimate message — treating it as a well-formed payload carrying no metrics is how a
/// truncated-to-nothing body gets mistaken for a real one. It is reported as invalid.
///
///
/// Explicit presence is preserved, everywhere. The vendored schema is proto2 precisely
/// so absence and zero stay distinguishable, and every projection below reads
/// Has{Seq,Name,Alias,Datatype,Timestamp,IsNull} rather than testing a value against its
/// default. Two cases make this load-bearing rather than pedantic: a Sparkplug NBIRTH is
/// REQUIRED to carry seq = 0, and every DATA metric after a birth carries an alias with
/// no name and no datatype. A zero-check decoder reports "no sequence" for every
/// birth and "" for every DATA metric name.
///
///
/// Values are projected raw — nothing is coerced or reinterpreted here. A metric's value
/// arrives as the CLR type of the wire field that carried it (see
/// ); coercion against the authored tag's declared
/// DriverDataType is the consumer's job, and follows this driver's standing rule that a
/// value which does not fit is refused rather than silently converted. The one wire-level
/// translation this type does offer is , because Sparkplug's
/// two's-complement-in-an-unsigned-field encoding of signed integers is a property of the
/// wire, not of the tag.
///
///
/// DataSet and Template metrics are out of scope for v1. They decode to
/// with a null value — deliberately visible, so a
/// consumer can warn about a metric it cannot serve instead of silently treating it as one
/// that was never published.
///
///
public static class SparkplugCodec
{
/// Decodes Sparkplug-B wire bytes, reporting success rather than throwing.
/// The MQTT message body.
///
/// The decoded payload on success; otherwise. Never
/// .
///
///
/// when the bytes parsed as a Sparkplug-B Payload. Protobuf is a
/// permissive, self-describing-only-by-convention format, so this means "parsed" rather than
/// "was genuinely produced by a Sparkplug edge node" — unknown fields are preserved by
/// Google.Protobuf and simply ignored here.
///
public static bool TryDecode(ReadOnlySpan wire, out SparkplugPayload payload)
{
payload = SparkplugPayload.Invalid;
if (wire.IsEmpty)
{
return false;
}
Payload proto;
try
{
proto = Payload.Parser.ParseFrom(wire);
}
catch (Exception)
{
// Deliberately broad. InvalidProtocolBufferException covers malformed, truncated and
// over-nested input, but this is the dispatcher-thread boundary: the contract is a
// verdict for EVERY input, and narrowing the catch would trade that guarantee for a
// taxonomy nobody downstream can act on.
return false;
}
var metrics = proto.Metrics.Count == 0
? []
: new SparkplugMetric[proto.Metrics.Count];
for (var i = 0; i < proto.Metrics.Count; i++)
{
metrics[i] = ProjectMetric(proto.Metrics[i]);
}
payload = new SparkplugPayload(
IsValid: true,
Seq: proto.HasSeq ? proto.Seq : null,
TimestampMs: proto.HasTimestamp ? proto.Timestamp : null,
Metrics: metrics);
return true;
}
///
/// Decodes Sparkplug-B wire bytes, returning when they
/// cannot be decoded.
///
/// The MQTT message body.
/// The decoded payload — never .
///
/// The convenience shape. Check : an undecodable
/// body and a genuinely metric-less one both present as an empty
/// , and only the flag tells them apart. Prefer
/// where the verdict drives control flow.
///
public static SparkplugPayload Decode(ReadOnlySpan wire) =>
TryDecode(wire, out var payload) ? payload : SparkplugPayload.Invalid;
///
/// Reinterprets a raw metric value as the signed integer Sparkplug encoded it as, when its
/// datatype is a signed integer type; returns the value unchanged for every other datatype.
///
///
/// A — a for the 8/16/32-bit arms, a
/// for the 64-bit arm.
///
/// The metric's Sparkplug datatype, from its birth or its own field.
///
/// / / / for the
/// signed integer datatypes; untouched otherwise (including when it is
/// or not the wire type the datatype implies).
///
///
/// Sparkplug carries Int8/Int16/Int32 in the unsigned
/// int_value field and Int64 in the unsigned long_value field, as two's
/// complement. Handing the raw field to a consumer publishes 4294967254 for a tag whose
/// value is -42 — a wrong value that looks entirely plausible, arrives with Good
/// quality, and is invisible until someone reads a gauge. This is the single call that undoes
/// it, and it is separate from the decode itself because a DATA metric carries no datatype of
/// its own: only the consumer, holding the alias table built from the birth, knows which
/// datatype applies.
///
public static object? ReinterpretSigned(object? value, TahuDataType datatype) => datatype switch
{
TahuDataType.Int8 when value is uint raw => unchecked((sbyte)raw),
TahuDataType.Int16 when value is uint raw => unchecked((short)raw),
TahuDataType.Int32 when value is uint raw => unchecked((int)raw),
TahuDataType.Int64 when value is ulong raw => unchecked((long)raw),
_ => value,
};
/// Projects one generated metric onto the driver-side shape, presence intact.
/// The generated metric.
/// The projection.
private static SparkplugMetric ProjectMetric(Payload.Types.Metric metric)
{
var (kind, value) = ProjectValue(metric);
return new SparkplugMetric(
Name: metric.HasName ? metric.Name : null,
Alias: metric.HasAlias ? metric.Alias : null,
// Cast, never filter: an index this build's enum does not define is a future Sparkplug
// revision or a misbehaving publisher, and dropping it would deny the mapping layer the
// only evidence it has.
DataType: metric.HasDatatype ? (TahuDataType)metric.Datatype : null,
TimestampMs: metric.HasTimestamp ? metric.Timestamp : null,
ValueKind: kind,
Value: value);
}
/// Resolves a metric's value oneof to a kind plus a raw CLR value.
/// The generated metric.
/// The value kind and the projected value.
///
/// is_null wins over the oneof: the Sparkplug spec's whole reason for the field is
/// that some datatypes have no spare sentinel, so a publisher that sets it means "null" even if
/// a value field is also populated.
///
private static (SparkplugValueKind Kind, object? Value) ProjectValue(Payload.Types.Metric metric)
{
if (metric.HasIsNull && metric.IsNull)
{
return (SparkplugValueKind.Null, null);
}
return metric.ValueCase switch
{
Payload.Types.Metric.ValueOneofCase.IntValue => (SparkplugValueKind.Scalar, metric.IntValue),
Payload.Types.Metric.ValueOneofCase.LongValue => (SparkplugValueKind.Scalar, metric.LongValue),
Payload.Types.Metric.ValueOneofCase.FloatValue => (SparkplugValueKind.Scalar, metric.FloatValue),
Payload.Types.Metric.ValueOneofCase.DoubleValue => (SparkplugValueKind.Scalar, metric.DoubleValue),
Payload.Types.Metric.ValueOneofCase.BooleanValue => (SparkplugValueKind.Scalar, metric.BooleanValue),
Payload.Types.Metric.ValueOneofCase.StringValue => (SparkplugValueKind.Scalar, metric.StringValue),
// Copied out of the ByteString: the projection must outlive the parsed message.
Payload.Types.Metric.ValueOneofCase.BytesValue =>
(SparkplugValueKind.Scalar, metric.BytesValue.ToByteArray()),
// v1 scope boundary — decoded as a visible refusal, not as an exception or a silent drop.
Payload.Types.Metric.ValueOneofCase.DatasetValue => (SparkplugValueKind.Unsupported, null),
Payload.Types.Metric.ValueOneofCase.TemplateValue => (SparkplugValueKind.Unsupported, null),
Payload.Types.Metric.ValueOneofCase.ExtensionValue => (SparkplugValueKind.Unsupported, null),
_ => (SparkplugValueKind.Absent, null),
};
}
}
///
/// A decoded Sparkplug-B payload: the driver-side projection of the generated Payload,
/// carrying only what the ingest state machine consumes.
///
///
/// for a payload that could not be decoded. An invalid payload also has an
/// empty list, so this flag is the only thing distinguishing an
/// undecodable body from a legitimately metric-less one.
///
///
/// The payload sequence number, or when the message carried none. Kept as
/// the wire's rather than narrowed to a byte: the Sparkplug range is 0–255, but
/// a publisher that violates it is reporting a fact the consumer should be able to see and reject,
/// not one this layer should silently truncate into a plausible-looking sequence number.
///
/// The payload timestamp in Sparkplug epoch milliseconds, or null when absent.
/// The payload's metrics, in wire order. Never .
public sealed record SparkplugPayload(
bool IsValid,
ulong? Seq,
ulong? TimestampMs,
IReadOnlyList Metrics)
{
/// The shared "could not decode this" result.
public static readonly SparkplugPayload Invalid = new(false, null, null, []);
}
/// One decoded Sparkplug metric.
///
/// The metric's stable name, or when the message omitted it — which every
/// real DATA metric after a birth does, carrying only . Distinguishing
/// this from an empty string is the reason the vendored schema is proto2.
///
/// The per-birth alias, or when the metric carried none.
///
/// The metric's declared datatype, or when absent (again, the normal case
/// for a DATA metric — its datatype comes from the birth). An index this build's enum does not
/// define is preserved as an undefined enum value rather than dropped.
///
///
/// The metric's own acquisition timestamp in Sparkplug epoch milliseconds, or
/// when it carried none — in which case the payload's timestamp applies.
///
///
/// What means. Check this before reading the value: a null value is
/// ambiguous between "explicitly null", "absent" and "a kind v1 does not support".
///
///
/// The raw wire value, boxed: (int_value),
/// (long_value), , , ,
/// or [] — and for every
/// other than .
///
/// Signed integers arrive as their unsigned two's-complement wire value — a
/// DataType.Int32 metric holding -42 is a of 4294967254 here. Run it
/// through with the metric's datatype (from the
/// birth, for a DATA metric) before publishing it.
///
///
/// Boxing is deliberate: the consumer coerces against the authored tag's declared
/// DriverDataType and hands the result to a DataValueSnapshot, which boxes
/// anyway, so a discriminated-union shape would buy an unboxing hop and cost every consumer a
/// switch over a dozen arms.
///
///
public readonly record struct SparkplugMetric(
string? Name,
ulong? Alias,
TahuDataType? DataType,
ulong? TimestampMs,
SparkplugValueKind ValueKind,
object? Value);
/// What a decoded metric's represents.
///
/// Three of the four members have a null and mean entirely
/// different things, which is exactly why the distinction is carried explicitly rather than left
/// for a consumer to infer from a null.
///
public enum SparkplugValueKind
{
/// The metric's value oneof carried nothing at all.
Absent = 0,
/// The metric set is_null: it exists, and its value is explicitly null.
Null,
/// holds the raw wire value.
Scalar,
///
/// A DataSet, Template or extension value — decoded and reported, but not
/// supported in v1. The consumer should warn and skip the metric rather than treat it as
/// missing.
///
Unsupported,
}