using System.Collections; using System.Globalization; using System.Text.Json; using Google.Protobuf; namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc; /// /// Codec for the loosely-typed object? members that survive on the site /// command plane — script parameters and return values, attribute values, and /// OPC UA tag read/write values — mapping them to and from the type-tagged /// proto carrier. /// /// /// /// Why a tagged union rather than a string or JSON blob. These values /// reach an operator's screen (Test Bindings, Debug View) and a device write /// (WriteTag), so collapsing them to text would change behaviour: today /// the Akka JSON serializer runs with TypeNameHandling on and preserves /// the boxed CLR type end-to-end. The tags below cover every CLR type these /// fields actually carry, so those values keep their runtime type across the /// wire exactly as they do over Akka remoting. /// /// /// The one documented lossy path. Anything outside the tagged set — /// an exotic numeric (, , …), an enum, a /// POCO — falls back to and decodes as a /// rather than its original CLR type. The value /// itself is preserved; its CLR identity is not. Collections and string-keyed /// dictionaries do NOT take that path: they encode recursively (see /// /) and decode to /// List<object?> / Dictionary<string, object?>, so /// their ELEMENTS keep their types while the container type widens. /// /// /// Why dates ride as strings. google.protobuf.Timestamp /// normalises everything to UTC, which silently discards /// and . For a /// timestamped tag value that is data loss, not normalisation, so /// // /// use invariant round-trip formats instead. (Message FIELDS that are declared /// DateTimeOffset in the DTO are a different case and do use /// Timestamp — see .) /// /// public static class LooseValueCodec { private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = false }; /// /// Encodes a boxed value for a NULLABLE message field: null returns /// null so the field is simply left unset on the wire. /// /// The boxed value to encode, or null. /// The encoded carrier, or null when is null. public static LooseValue? ToProtoOrNull(object? value) => value is null ? null : ToProto(value); /// /// Encodes a boxed value, representing null explicitly as /// . Used inside maps and lists, where an absent /// entry means "no such key/element" rather than "a null value". /// /// The boxed value to encode, or null. /// A populated ; never null. public static LooseValue ToProto(object? value) => value switch { null => new LooseValue { NullValue = new LooseNull() }, bool b => new LooseValue { BoolValue = b }, int i => new LooseValue { Int32Value = i }, long l => new LooseValue { Int64Value = l }, double d => new LooseValue { DoubleValue = d }, float f => new LooseValue { FloatValue = f }, string s => new LooseValue { StringValue = s }, decimal m => new LooseValue { DecimalValue = m.ToString(CultureInfo.InvariantCulture) }, DateTime dt => new LooseValue { DateTimeValue = dt.ToString("O", CultureInfo.InvariantCulture) }, DateTimeOffset dto => new LooseValue { DateTimeOffsetValue = dto.ToString("O", CultureInfo.InvariantCulture) }, Guid g => new LooseValue { GuidValue = g.ToString("D") }, TimeSpan ts => new LooseValue { TimeSpanValue = ts.ToString("c", CultureInfo.InvariantCulture) }, byte[] bytes => new LooseValue { BytesValue = ByteString.CopyFrom(bytes) }, IDictionary dict => new LooseValue { MapValue = ToMap(dict) }, IEnumerable seq => new LooseValue { ListValue = ToList(seq) }, // Escape hatch. Preserves the value, not the CLR type — see the remarks. _ => new LooseValue { JsonValue = JsonSerializer.Serialize(value, value.GetType(), JsonOpts) } }; /// /// Decodes a carrier back to a boxed value. An unset field /// (null) and an explicit both decode to /// null, so the two encoders above are interchangeable on read. /// /// The carrier to decode, or null when the field was unset. /// The decoded boxed value; null for an unset or explicitly-null carrier. public static object? FromProto(LooseValue? value) { if (value is null) { return null; } return value.KindCase switch { LooseValue.KindOneofCase.None => null, LooseValue.KindOneofCase.NullValue => null, LooseValue.KindOneofCase.BoolValue => value.BoolValue, LooseValue.KindOneofCase.Int32Value => value.Int32Value, LooseValue.KindOneofCase.Int64Value => value.Int64Value, LooseValue.KindOneofCase.DoubleValue => value.DoubleValue, LooseValue.KindOneofCase.FloatValue => value.FloatValue, LooseValue.KindOneofCase.StringValue => value.StringValue, LooseValue.KindOneofCase.DecimalValue => decimal.Parse(value.DecimalValue, CultureInfo.InvariantCulture), LooseValue.KindOneofCase.DateTimeValue => DateTime.Parse(value.DateTimeValue, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), LooseValue.KindOneofCase.DateTimeOffsetValue => DateTimeOffset.Parse(value.DateTimeOffsetValue, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), LooseValue.KindOneofCase.GuidValue => Guid.Parse(value.GuidValue), LooseValue.KindOneofCase.TimeSpanValue => TimeSpan.ParseExact(value.TimeSpanValue, "c", CultureInfo.InvariantCulture), LooseValue.KindOneofCase.BytesValue => value.BytesValue.ToByteArray(), LooseValue.KindOneofCase.ListValue => FromList(value.ListValue), LooseValue.KindOneofCase.MapValue => FromMap(value.MapValue), LooseValue.KindOneofCase.JsonValue => JsonSerializer.Deserialize(value.JsonValue), _ => null }; } /// /// Encodes a string-keyed dictionary onto the wire. Null values are carried /// explicitly (), so a key present with a null value /// stays distinct from an absent key. /// /// The dictionary to encode. /// A populated . public static LooseValueMap ToProtoMap(IReadOnlyDictionary values) { ArgumentNullException.ThrowIfNull(values); var map = new LooseValueMap(); foreach (var (key, value) in values) { map.Entries[key] = ToProto(value); } return map; } /// /// Encodes a NULLABLE string-keyed dictionary: null returns /// null so the field is left unset and the null/empty distinction /// survives (proto3 map alone cannot express it). /// /// The dictionary to encode, or null. /// The encoded map, or null when is null. public static LooseValueMap? ToProtoMapOrNull(IReadOnlyDictionary? values) => values is null ? null : ToProtoMap(values); /// Decodes a wire map back to a dictionary; an unset field decodes to null. /// The wire map, or null when the field was unset. /// The decoded dictionary, or null. public static IReadOnlyDictionary? FromProtoMapOrNull(LooseValueMap? map) => map is null ? null : FromMap(map); /// Decodes a wire map back to a dictionary; an unset field decodes to an EMPTY dictionary. /// For DTO members that are declared non-nullable, so "absent" can only mean "empty". /// The wire map, or null when the field was unset. /// The decoded dictionary; empty when is null. public static IReadOnlyDictionary FromProtoMap(LooseValueMap? map) => map is null ? new Dictionary() : FromMap(map); private static LooseValueList ToList(IEnumerable source) { var list = new LooseValueList(); foreach (var element in source) { list.Items.Add(ToProto(element)); } return list; } private static LooseValueMap ToMap(IDictionary source) { var map = new LooseValueMap(); foreach (DictionaryEntry entry in source) { // Non-string keys are outside the wire contract; the invariant-culture // rendering keeps the entry readable rather than dropping it silently. var key = entry.Key as string ?? Convert.ToString(entry.Key, CultureInfo.InvariantCulture) ?? string.Empty; map.Entries[key] = ToProto(entry.Value); } return map; } private static List FromList(LooseValueList list) { var result = new List(list.Items.Count); foreach (var item in list.Items) { result.Add(FromProto(item)); } return result; } private static Dictionary FromMap(LooseValueMap map) { var result = new Dictionary(map.Entries.Count); foreach (var (key, value) in map.Entries) { result[key] = FromProto(value); } return result; } }