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. /// /// /// Template and DataSet metrics decode to /// with their members flattened into /// — a template member Speed of metric /// Motor becomes the addressable Motor/Speed. See /// for the mapping and its bounds. Anything that cannot be flattened honestly stays /// with a null value — deliberately visible, so a /// consumer warns about a metric it cannot serve instead of silently treating it as one that was /// never published, and never coerces it into a guessed scalar. /// /// /// A metric's PropertySet is projected to /// as node metadata, never as sibling values: a PropertySet describes a metric (units, ranges, /// quality hints); it is not something the metric is. /// /// 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; } try { var proto = Payload.Parser.ParseFrom(wire); 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; } catch (Exception) { // Deliberately broad, and deliberately spanning the projection as well as the parse. // InvalidProtocolBufferException covers malformed, truncated and over-nested input, but // this is the dispatcher-thread boundary and the projection walks an attacker-shaped // object graph: the contract is a verdict for EVERY input, so a guard that stopped at // the parse would be a contract with a hole in it. Narrowing the catch would trade the // guarantee for a taxonomy nobody downstream can act on. payload = SparkplugPayload.Invalid; return false; } } /// /// 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, members) = 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, Members: members, Properties: SparkplugFlattener.ProjectProperties(metric.Properties)); } /// /// Resolves a template member's value oneof. Internal to the assembly: shared with /// , which walks nested templates and needs the identical /// projection so a member and a top-level metric of the same type never decode differently. /// /// The template member, itself a generated Metric. /// The value kind and the projected raw value. internal static (SparkplugValueKind Kind, object? Value) ProjectMemberValue(Payload.Types.Metric metric) { var (kind, value, _) = ProjectValue(metric); return (kind, 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, IReadOnlyList Members) ProjectValue( Payload.Types.Metric metric) { if (metric.HasIsNull && metric.IsNull) { return (SparkplugValueKind.Null, null, []); } // structured values flatten to addressable members. A refusal to flatten falls // through to Unsupported — the previous behaviour — rather than to a guessed scalar. switch (metric.ValueCase) { case Payload.Types.Metric.ValueOneofCase.TemplateValue: return SparkplugFlattener.TryFlattenTemplate(metric.TemplateValue, out var templateMembers) ? (SparkplugValueKind.Structured, null, templateMembers) : (SparkplugValueKind.Unsupported, null, []); case Payload.Types.Metric.ValueOneofCase.DatasetValue: return SparkplugFlattener.TryFlattenDataSet(metric.DatasetValue, out var cells) ? (SparkplugValueKind.Structured, null, cells) : (SparkplugValueKind.Unsupported, null, []); } return metric.ValueCase switch { Payload.Types.Metric.ValueOneofCase.IntValue => (SparkplugValueKind.Scalar, metric.IntValue, Empty), Payload.Types.Metric.ValueOneofCase.LongValue => (SparkplugValueKind.Scalar, metric.LongValue, Empty), Payload.Types.Metric.ValueOneofCase.FloatValue => (SparkplugValueKind.Scalar, metric.FloatValue, Empty), Payload.Types.Metric.ValueOneofCase.DoubleValue => (SparkplugValueKind.Scalar, metric.DoubleValue, Empty), Payload.Types.Metric.ValueOneofCase.BooleanValue => (SparkplugValueKind.Scalar, metric.BooleanValue, Empty), Payload.Types.Metric.ValueOneofCase.StringValue => (SparkplugValueKind.Scalar, metric.StringValue, Empty), // Copied out of the ByteString: the projection must outlive the parsed message. Payload.Types.Metric.ValueOneofCase.BytesValue => (SparkplugValueKind.Scalar, metric.BytesValue.ToByteArray(), Empty), // An extension value is a third-party escape hatch with no defined semantics — there is // nothing honest to flatten it into. Payload.Types.Metric.ValueOneofCase.ExtensionValue => (SparkplugValueKind.Unsupported, null, Empty), _ => (SparkplugValueKind.Absent, null, Empty), }; } /// The shared empty member list — every scalar metric would otherwise allocate one. private static readonly IReadOnlyList Empty = []; } /// /// 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. /// /// /// /// The flattened members of a metric — a /// Template or DataSet broken into addressable scalars. Empty for every /// other kind. Paths are relative to this metric; see . /// /// /// The metric's PropertySet, projected as node metadata. Empty when the metric /// carried none. Never a value — a PropertySet describes the metric rather than being one. /// public readonly record struct SparkplugMetric( string? Name, ulong? Alias, TahuDataType? DataType, ulong? TimestampMs, SparkplugValueKind ValueKind, object? Value, IReadOnlyList? Members = null, IReadOnlyList? Properties = null) { /// The flattened members, never null. public IReadOnlyList MemberList => Members ?? []; /// The projected properties, never null. public IReadOnlyList PropertyList => Properties ?? []; } /// 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, /// /// An extension value, or a Template/DataSet that could not be flattened /// honestly (see ). Decoded and reported, but not servable: /// the consumer warns and skips the metric rather than treating it as missing — and never /// coerces it into a guessed scalar. /// Unsupported, /// /// A Template or DataSet whose members were flattened into /// . is /// null — the parent has no scalar value of its own; its members carry the values. /// Structured, }