59b13d317b
Phase 1B's contract slice: the wire shape and the canonical translation for the 28 central→site commands that leave ClusterClient. No behaviour changes yet — SiteCommunicationActor and CentralCommunicationActor are untouched; the dispatcher refactor (T1B.2) and the central transport seam (T1B.3) consume this. Protos/site_command.proto (package scadabridge.sitecommand.v1, service SiteCommandService): six domain RPCs, each with a `oneof` request/reply envelope. The grouping is what carries deadline policy — every command inside a group shares a CommunicationOptions timeout class today, so one RPC per group keeps the deadline choice in one place on the client and one dispatch switch on the server, while the oneof keeps each command individually typed: ExecuteLifecycle(6) · ExecuteOpcUa(8) · ExecuteQuery(4) · ExecuteParked(5) · ExecuteRoute(4) · TriggerFailover(1). IntegrationCallRequest — the 29th entry on SiteCommunicationActor's receive table — is deliberately excluded as dead code (2026-07-22-integration-call-routing-is-dead-code.md). Contract decisions worth knowing: - Nullable COLLECTIONS ride in per-collection wrapper messages (DeployArtifactsCommand's six artifact lists, CertTrustResult.Certs, RouteToCallRequest.Parameters). proto3 repeated/map collapses null into empty, and that distinction is live at the site — the same silent-data-loss class the transport round-trip guard exposed in PLAN-05 T8. Goldens cover null, empty and populated for each. - Nullable strings use the empty-string-means-null convention already set by AuditEventDtoMapper, with ONE exception: RouteToWaitForAttributeRequest's TargetValueEncoded, where "wait for the empty string" is a real target, so it carries a StringValue wrapper. Both behaviours are asserted, not assumed. - Nullable enums ride in one-field messages (proto3 enums have no presence and no stock wrapper). Enum translation is an explicit switch in both directions — never by ordinal — so reordering a C# enum cannot re-map the wire; every wire enum reserves 0 for _UNSPECIFIED and decodes to a documented safe default rather than faulting a command from a version-skewed peer. - New LooseValueCodec carries the surviving `object?` members (script params and return values, attribute values, tag read/write values) as a type-tagged union so a boxed value keeps its runtime CLR type, as it does today under Akka's type-preserving JSON serializer. Dates ride as invariant round-trip strings, not Timestamp, which would silently normalise away DateTime.Kind and DateTimeOffset.Offset. Lists/maps recurse; anything outside the tagged set falls back to JSON and is documented as CLR-type-lossy. - DebugViewSnapshot gets its own full-fidelity alarm/attribute messages rather than reusing sitestream's AlarmStateUpdate, which flattens values to display strings — right for a live stream, lossy for a snapshot the UI treats as authoritative. The encoder omits an AlarmStateChanged.Condition that already equals the record's derived default, so computed alarms round-trip exactly (record equality compares the nullable backing field, not the property). Tests are reflection-driven so the coverage cannot drift: the round-trip theory enumerates the mapper's own ToProto overloads, the envelope guards enumerate the generated oneof descriptors, and a missing golden fails the build. 216 new tests green (Communication 532 total, Commons 684 total, solution build 0/0). Codegen is checked in under SiteCommandGrpc/ per the sitestream recipe; the <Protobuf> ItemGroup stays commented out (an active one segfaults protoc in the linux_arm64 Docker image). docker/regen-proto.sh now handles every proto in that ItemGroup instead of just sitestream, and re-comments idempotently.
220 lines
10 KiB
C#
220 lines
10 KiB
C#
using System.Collections;
|
|
using System.Globalization;
|
|
using System.Text.Json;
|
|
using Google.Protobuf;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
|
|
|
/// <summary>
|
|
/// Codec for the loosely-typed <c>object?</c> 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
|
|
/// <see cref="LooseValue"/> proto carrier.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Why a tagged union rather than a string or JSON blob.</b> These values
|
|
/// reach an operator's screen (Test Bindings, Debug View) and a device write
|
|
/// (<c>WriteTag</c>), so collapsing them to text would change behaviour: today
|
|
/// the Akka JSON serializer runs with <c>TypeNameHandling</c> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>The one documented lossy path.</b> Anything outside the tagged set —
|
|
/// an exotic numeric (<see cref="byte"/>, <see cref="uint"/>, …), an enum, a
|
|
/// POCO — falls back to <see cref="LooseValue.JsonValue"/> and decodes as a
|
|
/// <see cref="JsonElement"/> 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
|
|
/// <see cref="LooseValueList"/>/<see cref="LooseValueMap"/>) and decode to
|
|
/// <c>List<object?></c> / <c>Dictionary<string, object?></c>, so
|
|
/// their ELEMENTS keep their types while the container type widens.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Why dates ride as strings.</b> <c>google.protobuf.Timestamp</c>
|
|
/// normalises everything to UTC, which silently discards
|
|
/// <see cref="DateTime.Kind"/> and <see cref="DateTimeOffset.Offset"/>. For a
|
|
/// timestamped tag value that is data loss, not normalisation, so
|
|
/// <see cref="DateTime"/>/<see cref="DateTimeOffset"/>/<see cref="TimeSpan"/>
|
|
/// use invariant round-trip formats instead. (Message FIELDS that are declared
|
|
/// <c>DateTimeOffset</c> in the DTO are a different case and do use
|
|
/// <c>Timestamp</c> — see <see cref="SiteCommandDtoMapper"/>.)
|
|
/// </para>
|
|
/// </remarks>
|
|
public static class LooseValueCodec
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = false };
|
|
|
|
/// <summary>
|
|
/// Encodes a boxed value for a NULLABLE message field: <c>null</c> returns
|
|
/// <c>null</c> so the field is simply left unset on the wire.
|
|
/// </summary>
|
|
/// <param name="value">The boxed value to encode, or <c>null</c>.</param>
|
|
/// <returns>The encoded carrier, or <c>null</c> when <paramref name="value"/> is <c>null</c>.</returns>
|
|
public static LooseValue? ToProtoOrNull(object? value) =>
|
|
value is null ? null : ToProto(value);
|
|
|
|
/// <summary>
|
|
/// Encodes a boxed value, representing <c>null</c> explicitly as
|
|
/// <see cref="LooseNull"/>. Used inside maps and lists, where an absent
|
|
/// entry means "no such key/element" rather than "a null value".
|
|
/// </summary>
|
|
/// <param name="value">The boxed value to encode, or <c>null</c>.</param>
|
|
/// <returns>A populated <see cref="LooseValue"/>; never <c>null</c>.</returns>
|
|
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) }
|
|
};
|
|
|
|
/// <summary>
|
|
/// Decodes a carrier back to a boxed value. An unset field
|
|
/// (<c>null</c>) and an explicit <see cref="LooseNull"/> both decode to
|
|
/// <c>null</c>, so the two encoders above are interchangeable on read.
|
|
/// </summary>
|
|
/// <param name="value">The carrier to decode, or <c>null</c> when the field was unset.</param>
|
|
/// <returns>The decoded boxed value; <c>null</c> for an unset or explicitly-null carrier.</returns>
|
|
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<JsonElement>(value.JsonValue),
|
|
_ => null
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Encodes a string-keyed dictionary onto the wire. Null values are carried
|
|
/// explicitly (<see cref="LooseNull"/>), so a key present with a null value
|
|
/// stays distinct from an absent key.
|
|
/// </summary>
|
|
/// <param name="values">The dictionary to encode.</param>
|
|
/// <returns>A populated <see cref="LooseValueMap"/>.</returns>
|
|
public static LooseValueMap ToProtoMap(IReadOnlyDictionary<string, object?> values)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(values);
|
|
|
|
var map = new LooseValueMap();
|
|
foreach (var (key, value) in values)
|
|
{
|
|
map.Entries[key] = ToProto(value);
|
|
}
|
|
|
|
return map;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Encodes a NULLABLE string-keyed dictionary: <c>null</c> returns
|
|
/// <c>null</c> so the field is left unset and the null/empty distinction
|
|
/// survives (proto3 <c>map</c> alone cannot express it).
|
|
/// </summary>
|
|
/// <param name="values">The dictionary to encode, or <c>null</c>.</param>
|
|
/// <returns>The encoded map, or <c>null</c> when <paramref name="values"/> is <c>null</c>.</returns>
|
|
public static LooseValueMap? ToProtoMapOrNull(IReadOnlyDictionary<string, object?>? values) =>
|
|
values is null ? null : ToProtoMap(values);
|
|
|
|
/// <summary>Decodes a wire map back to a dictionary; an unset field decodes to <c>null</c>.</summary>
|
|
/// <param name="map">The wire map, or <c>null</c> when the field was unset.</param>
|
|
/// <returns>The decoded dictionary, or <c>null</c>.</returns>
|
|
public static IReadOnlyDictionary<string, object?>? FromProtoMapOrNull(LooseValueMap? map) =>
|
|
map is null ? null : FromMap(map);
|
|
|
|
/// <summary>Decodes a wire map back to a dictionary; an unset field decodes to an EMPTY dictionary.</summary>
|
|
/// <remarks>For DTO members that are declared non-nullable, so "absent" can only mean "empty".</remarks>
|
|
/// <param name="map">The wire map, or <c>null</c> when the field was unset.</param>
|
|
/// <returns>The decoded dictionary; empty when <paramref name="map"/> is <c>null</c>.</returns>
|
|
public static IReadOnlyDictionary<string, object?> FromProtoMap(LooseValueMap? map) =>
|
|
map is null ? new Dictionary<string, object?>() : 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<object?> FromList(LooseValueList list)
|
|
{
|
|
var result = new List<object?>(list.Items.Count);
|
|
foreach (var item in list.Items)
|
|
{
|
|
result.Add(FromProto(item));
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static Dictionary<string, object?> FromMap(LooseValueMap map)
|
|
{
|
|
var result = new Dictionary<string, object?>(map.Entries.Count);
|
|
foreach (var (key, value) in map.Entries)
|
|
{
|
|
result[key] = FromProto(value);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
}
|