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, }