feat(grpc): T1B.1 — site_command.proto + SiteCommandDtoMapper + round-trip goldens
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.
This commit is contained in:
+20
-7
@@ -3,14 +3,15 @@
|
||||
# Regenerates the gRPC C# files from the Communication project's .proto files.
|
||||
#
|
||||
# Background: protoc (linux/arm64) segfaults inside our Docker build container
|
||||
# (Grpc.Tools 2.71.0). As a workaround the generated C# is checked into
|
||||
# src/ZB.MOM.WW.ScadaBridge.Communication/ — SiteStreamGrpc/ for sitestream.proto
|
||||
# and CentralControlGrpc/ for central_control.proto — and the Protobuf ItemGroup
|
||||
# in the .csproj is commented out, so Docker just compiles the checked-in files.
|
||||
# (Grpc.Tools). As a workaround the generated C# is checked into
|
||||
# src/ZB.MOM.WW.ScadaBridge.Communication/ — SiteStreamGrpc/ for sitestream.proto,
|
||||
# CentralControlGrpc/ for central_control.proto, and SiteCommandGrpc/ for
|
||||
# site_command.proto — and the Protobuf ItemGroup in the .csproj is commented out,
|
||||
# so Docker just compiles the checked-in files.
|
||||
#
|
||||
# Run this script ON YOUR DEV MACHINE whenever a .proto changes:
|
||||
#
|
||||
# docker/regen-proto.sh [sitestream|centralcontrol|all] (default: all)
|
||||
# docker/regen-proto.sh [sitestream|centralcontrol|sitecommand|all] (default: all)
|
||||
#
|
||||
# 1. Injects a Protobuf ItemGroup for the selected proto(s) so Grpc.Tools runs.
|
||||
# 2. Deletes the stale checked-in C# so a failed regen is obvious.
|
||||
@@ -32,8 +33,8 @@ set -euo pipefail
|
||||
|
||||
TARGET="${1:-all}"
|
||||
case "$TARGET" in
|
||||
sitestream|centralcontrol|all) ;;
|
||||
*) echo "usage: $0 [sitestream|centralcontrol|all]" >&2; exit 2 ;;
|
||||
sitestream|centralcontrol|sitecommand|all) ;;
|
||||
*) echo "usage: $0 [sitestream|centralcontrol|sitecommand|all]" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
@@ -68,6 +69,8 @@ if target in ("sitestream", "all"):
|
||||
protos.append("sitestream.proto")
|
||||
if target in ("centralcontrol", "all"):
|
||||
protos.append("central_control.proto")
|
||||
if target in ("sitecommand", "all"):
|
||||
protos.append("site_command.proto")
|
||||
|
||||
items = "\n".join(
|
||||
f' <Protobuf Include="Protos\\{p}" GrpcServices="Both" />' for p in protos)
|
||||
@@ -87,6 +90,10 @@ if [[ "$TARGET" == "centralcontrol" || "$TARGET" == "all" ]]; then
|
||||
rm -f "$COMM_DIR/CentralControlGrpc/CentralControl.cs" \
|
||||
"$COMM_DIR/CentralControlGrpc/CentralControlGrpc.cs"
|
||||
fi
|
||||
if [[ "$TARGET" == "sitecommand" || "$TARGET" == "all" ]]; then
|
||||
rm -f "$COMM_DIR/SiteCommandGrpc/SiteCommand.cs" \
|
||||
"$COMM_DIR/SiteCommandGrpc/SiteCommandGrpc.cs"
|
||||
fi
|
||||
|
||||
# 3. Regenerate by building.
|
||||
echo "Building Communication project (regen)..."
|
||||
@@ -103,6 +110,11 @@ if [[ "$TARGET" == "centralcontrol" || "$TARGET" == "all" ]]; then
|
||||
cp "$GEN/CentralControl.cs" "$GEN/CentralControlGrpc.cs" "$COMM_DIR/CentralControlGrpc/"
|
||||
echo "Copied regenerated files to CentralControlGrpc/"
|
||||
fi
|
||||
if [[ "$TARGET" == "sitecommand" || "$TARGET" == "all" ]]; then
|
||||
mkdir -p "$COMM_DIR/SiteCommandGrpc"
|
||||
cp "$GEN/SiteCommand.cs" "$GEN/SiteCommandGrpc.cs" "$COMM_DIR/SiteCommandGrpc/"
|
||||
echo "Copied regenerated files to SiteCommandGrpc/"
|
||||
fi
|
||||
|
||||
# 5. Restore the backed-up csproj — i.e. drop the injected ItemGroup — so Docker
|
||||
# builds keep working.
|
||||
@@ -115,4 +127,5 @@ echo "Done. Review and commit:"
|
||||
echo " git diff src/ZB.MOM.WW.ScadaBridge.Communication/Protos/"
|
||||
echo " git diff src/ZB.MOM.WW.ScadaBridge.Communication/SiteStreamGrpc/"
|
||||
echo " git diff src/ZB.MOM.WW.ScadaBridge.Communication/CentralControlGrpc/"
|
||||
echo " git diff src/ZB.MOM.WW.ScadaBridge.Communication/SiteCommandGrpc/"
|
||||
echo " git diff -- src/ZB.MOM.WW.ScadaBridge.Communication/*.csproj # must be EMPTY"
|
||||
|
||||
@@ -313,10 +313,10 @@ Critical path ≈ 1B: **~4–6 weeks total**, matching the design estimate.
|
||||
- [x] T1A.2 Central hosting: `AddGrpc` + per-site-PSK interceptor (`x-scadabridge-site`), `CentralGrpcPort` h2c listener (8083), `CentralControlGrpcService` (Ask existing handlers), readiness gate
|
||||
- [x] T1A.3 `ICentralTransport` (Akka extract + Grpc impl), `CentralChannelProvider` (sticky failover/failback, backoff, deadlines, PSK), `CentralTransport` flag default `Akka`, `CentralGrpcEndpoints` option + validator
|
||||
- [x] T1A.4 Tests: actor-with-fake-transport ×7, TestServer transport tests, S&F/audit/health suites pass unmodified
|
||||
- [ ] 1A DoD: rig site-a on `Grpc` proves all 5 site→central paths while site-b/c stay Akka; PR merged (before 1B)
|
||||
- [x] 1A DoD: rig site-a on `Grpc` proves site→central paths (heartbeat/health/reconcile + coexistence) while site-b/c stay Akka; PR #26 merged (`aa60f438`). Notification/audit deferred to Phase 2 soak (no deployed instance); rig caught + fixed a central `:5000` HTTP-drop regression (`0e162cb2`)
|
||||
|
||||
**Phase 1B — site command plane** (worktree, `feat/grpc-sitecommand`)
|
||||
- [x] T1B.1 `site_command.proto` (6 oneof RPCs / 28 commands) + `SiteCommandDtoMapper` + round-trip golden tests (all 28 + replies)
|
||||
- [x] T1B.1 `site_command.proto` (6 oneof RPCs / 28 commands) + `SiteCommandDtoMapper` + round-trip golden tests (all 28 commands + 22 reply shapes + 18 nested types; reflection-driven coverage guard over the mapper surface and the generated oneof descriptors)
|
||||
- [x] T1B.2 `SiteCommandDispatcher` refactor (actor + new `SiteCommandGrpcService` share it; parked stays node-local)
|
||||
- [x] T1B.3 `ISiteCommandTransport` in `CentralCommunicationActor` (Akka extract + Grpc impl), `SitePairChannelProvider` (Site entity Grpc columns + DB refresh loop), `SiteTransport` flag default `Akka`
|
||||
- [x] T1B.4 Tests: dispatcher routing ×28, actor envelope/reply plumbing, TestServer service tests, existing Communication suites green
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// The six domain groups the 28 migrated central→site commands are partitioned
|
||||
/// into — one group per <c>SiteCommandService</c> RPC.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The partition is not cosmetic: every command inside a group shares a
|
||||
/// DEADLINE class today (the <c>CommunicationOptions</c> timeout the central
|
||||
/// <c>Ask</c> uses), so one RPC per group keeps the deadline decision in one
|
||||
/// place on the client and one dispatch switch on the server, while the
|
||||
/// <c>oneof</c> envelope keeps each command individually typed.
|
||||
/// </remarks>
|
||||
public enum SiteCommandGroup
|
||||
{
|
||||
/// <summary>Deployment refresh, instance enable/disable/delete, deployment-state query, artifact deployment.</summary>
|
||||
Lifecycle,
|
||||
|
||||
/// <summary>Interactive OPC UA / MxGateway design-time commands: browse, search, read, verify, cert trust, write tag.</summary>
|
||||
OpcUa,
|
||||
|
||||
/// <summary>Read-only remote queries: site event log and debug view snapshot/subscribe/unsubscribe.</summary>
|
||||
Query,
|
||||
|
||||
/// <summary>Parked store-and-forward message actions and parked cached-operation retry/discard relays.</summary>
|
||||
Parked,
|
||||
|
||||
/// <summary>Inbound-API <c>Route.To()</c> relays: call, get/set attributes, wait for attribute.</summary>
|
||||
Route,
|
||||
|
||||
/// <summary>Operator-initiated manual site-pair failover.</summary>
|
||||
Failover
|
||||
}
|
||||
@@ -0,0 +1,857 @@
|
||||
syntax = "proto3";
|
||||
option csharp_namespace = "ZB.MOM.WW.ScadaBridge.Communication.Grpc";
|
||||
package scadabridge.sitecommand.v1;
|
||||
|
||||
import "google/protobuf/timestamp.proto";
|
||||
import "google/protobuf/duration.proto";
|
||||
import "google/protobuf/wrappers.proto";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Site command plane (central → site) — the gRPC replacement for the per-site
|
||||
// ClusterClient command/control channel handled by SiteCommunicationActor.
|
||||
//
|
||||
// The 28 migrated commands are grouped into six domain RPCs rather than 28
|
||||
// individual RPCs, because the grouping is what carries the DEADLINE policy:
|
||||
// every command inside one group shares a timeout class today (see
|
||||
// CommunicationOptions), so one RPC per group keeps the deadline choice in one
|
||||
// place on the client and one dispatch switch on the server. A `oneof` request
|
||||
// / reply envelope preserves per-command typing inside the group.
|
||||
//
|
||||
// IntegrationCallRequest (the 29th command) is deliberately NOT here — it is
|
||||
// dead code at both ends; see
|
||||
// docs/known-issues/2026-07-22-integration-call-routing-is-dead-code.md.
|
||||
//
|
||||
// EVOLUTION RULE (same as sitestream.proto): field numbers are never reused and
|
||||
// changes are additive only. New commands take the next free oneof tag; an
|
||||
// older peer simply sees an unset oneof and answers with a clean error.
|
||||
//
|
||||
// The generated C# is CHECKED IN under Communication/SiteCommandGrpc/ because
|
||||
// protoc segfaults inside the linux_arm64 Docker build image. Regenerate with
|
||||
// docker/regen-proto.sh (or by hand per the recipe in the .csproj) — never by
|
||||
// leaving an active <Protobuf> item in the project file.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
service SiteCommandService {
|
||||
// Deployment + instance lifecycle (6 commands).
|
||||
rpc ExecuteLifecycle(LifecycleRequest) returns (LifecycleReply);
|
||||
// Interactive OPC UA / MxGateway design-time commands (8 commands).
|
||||
rpc ExecuteOpcUa(OpcUaRequest) returns (OpcUaReply);
|
||||
// Remote read-only queries: event log + debug view (4 commands).
|
||||
rpc ExecuteQuery(QueryRequest) returns (QueryReply);
|
||||
// Parked store-and-forward message + cached-operation actions (5 commands).
|
||||
rpc ExecuteParked(ParkedRequest) returns (ParkedReply);
|
||||
// Inbound-API Route.To() relays (4 commands).
|
||||
rpc ExecuteRoute(RouteRequest) returns (RouteReply);
|
||||
// Operator-initiated manual site-pair failover (1 command).
|
||||
rpc TriggerFailover(TriggerSiteFailoverDto) returns (SiteFailoverAckDto);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Shared value carrier
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Explicit null marker. Needed because a map<string, LooseValue> entry always
|
||||
// has a value message present, so "the dictionary holds a null for this key"
|
||||
// cannot be expressed by absence the way a nullable message FIELD can.
|
||||
message LooseNull {}
|
||||
|
||||
message LooseValueList { repeated LooseValue items = 1; }
|
||||
|
||||
message LooseValueMap { map<string, LooseValue> entries = 1; }
|
||||
|
||||
// Type-tagged carrier for the `object?` members the command plane still uses
|
||||
// (script parameters and return values, attribute values, tag read/write
|
||||
// values). The tags cover every CLR type these fields actually carry, so those
|
||||
// values round-trip with their runtime type intact; anything else falls back to
|
||||
// `json_value` (see the mapper's documented lossiness note).
|
||||
//
|
||||
// Date/time and decimal ride as invariant round-trip STRINGS rather than
|
||||
// google.protobuf.Timestamp: Timestamp normalises everything to UTC and would
|
||||
// silently drop DateTime.Kind / DateTimeOffset.Offset, which for an operator's
|
||||
// tag value is data loss, not normalisation.
|
||||
message LooseValue {
|
||||
oneof kind {
|
||||
LooseNull null_value = 1;
|
||||
bool bool_value = 2;
|
||||
int32 int32_value = 3;
|
||||
int64 int64_value = 4;
|
||||
double double_value = 5;
|
||||
float float_value = 6;
|
||||
string string_value = 7;
|
||||
string decimal_value = 8; // invariant-culture round-trip
|
||||
string date_time_value = 9; // DateTime, "O" (preserves Kind)
|
||||
string date_time_offset_value = 10; // DateTimeOffset, "O" (preserves Offset)
|
||||
string guid_value = 11; // "D"
|
||||
string time_span_value = 12; // "c" (constant/round-trip)
|
||||
bytes bytes_value = 13;
|
||||
LooseValueList list_value = 14;
|
||||
LooseValueMap map_value = 15;
|
||||
string json_value = 16; // fallback; decodes to JsonElement
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Enums. Every enum reserves 0 for _UNSPECIFIED so a value that is absent on
|
||||
// the wire is never mistaken for the first CLR member. The mapper translates
|
||||
// explicitly in both directions (never by ordinal), so reordering a C# enum
|
||||
// cannot silently re-map the wire.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
enum DeploymentStatusDto {
|
||||
DEPLOYMENT_STATUS_DTO_UNSPECIFIED = 0;
|
||||
DEPLOYMENT_STATUS_DTO_PENDING = 1;
|
||||
DEPLOYMENT_STATUS_DTO_IN_PROGRESS = 2;
|
||||
DEPLOYMENT_STATUS_DTO_SUCCESS = 3;
|
||||
DEPLOYMENT_STATUS_DTO_FAILED = 4;
|
||||
}
|
||||
|
||||
enum BrowseNodeClassDto {
|
||||
BROWSE_NODE_CLASS_DTO_UNSPECIFIED = 0;
|
||||
BROWSE_NODE_CLASS_DTO_OBJECT = 1;
|
||||
BROWSE_NODE_CLASS_DTO_VARIABLE = 2;
|
||||
BROWSE_NODE_CLASS_DTO_METHOD = 3;
|
||||
BROWSE_NODE_CLASS_DTO_OTHER = 4;
|
||||
}
|
||||
|
||||
enum BrowseFailureKindDto {
|
||||
BROWSE_FAILURE_KIND_DTO_UNSPECIFIED = 0;
|
||||
BROWSE_FAILURE_KIND_DTO_CONNECTION_NOT_FOUND = 1;
|
||||
BROWSE_FAILURE_KIND_DTO_CONNECTION_NOT_CONNECTED = 2;
|
||||
BROWSE_FAILURE_KIND_DTO_NOT_BROWSABLE = 3;
|
||||
BROWSE_FAILURE_KIND_DTO_TIMEOUT = 4;
|
||||
BROWSE_FAILURE_KIND_DTO_SERVER_ERROR = 5;
|
||||
}
|
||||
|
||||
enum ReadTagValuesFailureKindDto {
|
||||
READ_TAG_VALUES_FAILURE_KIND_DTO_UNSPECIFIED = 0;
|
||||
READ_TAG_VALUES_FAILURE_KIND_DTO_CONNECTION_NOT_FOUND = 1;
|
||||
READ_TAG_VALUES_FAILURE_KIND_DTO_CONNECTION_NOT_CONNECTED = 2;
|
||||
READ_TAG_VALUES_FAILURE_KIND_DTO_TIMEOUT = 3;
|
||||
READ_TAG_VALUES_FAILURE_KIND_DTO_SERVER_ERROR = 4;
|
||||
}
|
||||
|
||||
enum VerifyFailureKindDto {
|
||||
VERIFY_FAILURE_KIND_DTO_UNSPECIFIED = 0;
|
||||
VERIFY_FAILURE_KIND_DTO_UNREACHABLE = 1;
|
||||
VERIFY_FAILURE_KIND_DTO_AUTH_FAILED = 2;
|
||||
VERIFY_FAILURE_KIND_DTO_UNTRUSTED_CERTIFICATE = 3;
|
||||
VERIFY_FAILURE_KIND_DTO_TIMEOUT = 4;
|
||||
VERIFY_FAILURE_KIND_DTO_SERVER_ERROR = 5;
|
||||
}
|
||||
|
||||
enum StoreAndForwardCategoryDto {
|
||||
STORE_AND_FORWARD_CATEGORY_DTO_UNSPECIFIED = 0;
|
||||
STORE_AND_FORWARD_CATEGORY_DTO_EXTERNAL_SYSTEM = 1;
|
||||
STORE_AND_FORWARD_CATEGORY_DTO_NOTIFICATION = 2;
|
||||
STORE_AND_FORWARD_CATEGORY_DTO_CACHED_DB_WRITE = 3;
|
||||
}
|
||||
|
||||
enum AlarmStateDto {
|
||||
ALARM_STATE_DTO_UNSPECIFIED = 0;
|
||||
ALARM_STATE_DTO_ACTIVE = 1;
|
||||
ALARM_STATE_DTO_NORMAL = 2;
|
||||
}
|
||||
|
||||
enum AlarmLevelDto {
|
||||
ALARM_LEVEL_DTO_UNSPECIFIED = 0;
|
||||
ALARM_LEVEL_DTO_NONE = 1;
|
||||
ALARM_LEVEL_DTO_LOW = 2;
|
||||
ALARM_LEVEL_DTO_LOW_LOW = 3;
|
||||
ALARM_LEVEL_DTO_HIGH = 4;
|
||||
ALARM_LEVEL_DTO_HIGH_HIGH = 5;
|
||||
}
|
||||
|
||||
enum AlarmKindDto {
|
||||
ALARM_KIND_DTO_UNSPECIFIED = 0;
|
||||
ALARM_KIND_DTO_COMPUTED = 1;
|
||||
ALARM_KIND_DTO_NATIVE_OPC_UA = 2;
|
||||
ALARM_KIND_DTO_NATIVE_MX_ACCESS = 3;
|
||||
}
|
||||
|
||||
enum AlarmShelveStateDto {
|
||||
ALARM_SHELVE_STATE_DTO_UNSPECIFIED = 0;
|
||||
ALARM_SHELVE_STATE_DTO_UNSHELVED = 1;
|
||||
ALARM_SHELVE_STATE_DTO_ONE_SHOT_SHELVED = 2;
|
||||
ALARM_SHELVE_STATE_DTO_TIMED_SHELVED = 3;
|
||||
ALARM_SHELVE_STATE_DTO_PERMANENT_SHELVED = 4;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// RPC 1 — ExecuteLifecycle
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
message RefreshDeploymentCommandDto {
|
||||
string deployment_id = 1;
|
||||
string instance_unique_name = 2;
|
||||
string revision_hash = 3;
|
||||
string deployed_by = 4;
|
||||
google.protobuf.Timestamp timestamp = 5;
|
||||
string central_fetch_base_url = 6;
|
||||
string fetch_token = 7;
|
||||
}
|
||||
|
||||
message EnableInstanceCommandDto {
|
||||
string command_id = 1;
|
||||
string instance_unique_name = 2;
|
||||
google.protobuf.Timestamp timestamp = 3;
|
||||
}
|
||||
|
||||
message DisableInstanceCommandDto {
|
||||
string command_id = 1;
|
||||
string instance_unique_name = 2;
|
||||
google.protobuf.Timestamp timestamp = 3;
|
||||
}
|
||||
|
||||
message DeleteInstanceCommandDto {
|
||||
string command_id = 1;
|
||||
string instance_unique_name = 2;
|
||||
google.protobuf.Timestamp timestamp = 3;
|
||||
}
|
||||
|
||||
message DeploymentStateQueryRequestDto {
|
||||
string correlation_id = 1;
|
||||
string instance_unique_name = 2;
|
||||
google.protobuf.Timestamp timestamp = 3;
|
||||
}
|
||||
|
||||
message SharedScriptArtifactDto {
|
||||
string name = 1;
|
||||
string code = 2;
|
||||
string parameter_definitions = 3; // empty string represents null
|
||||
string return_definition = 4; // empty string represents null
|
||||
}
|
||||
|
||||
message ExternalSystemArtifactDto {
|
||||
string name = 1;
|
||||
string endpoint_url = 2;
|
||||
string auth_type = 3;
|
||||
string auth_configuration = 4; // empty string represents null
|
||||
string method_definitions_json = 5; // empty string represents null
|
||||
int32 timeout_seconds = 6;
|
||||
}
|
||||
|
||||
message DatabaseConnectionArtifactDto {
|
||||
string name = 1;
|
||||
string connection_string = 2;
|
||||
int32 max_retries = 3;
|
||||
google.protobuf.Duration retry_delay = 4;
|
||||
}
|
||||
|
||||
message NotificationListArtifactDto {
|
||||
string name = 1;
|
||||
repeated string recipient_emails = 2;
|
||||
}
|
||||
|
||||
message DataConnectionArtifactDto {
|
||||
string name = 1;
|
||||
string protocol = 2;
|
||||
string primary_configuration_json = 3; // empty string represents null
|
||||
string backup_configuration_json = 4; // empty string represents null
|
||||
int32 failover_retry_count = 5;
|
||||
}
|
||||
|
||||
message SmtpConfigurationArtifactDto {
|
||||
string name = 1;
|
||||
string server = 2;
|
||||
int32 port = 3;
|
||||
string auth_mode = 4;
|
||||
string from_address = 5;
|
||||
string username = 6; // empty string represents null
|
||||
string password = 7; // empty string represents null
|
||||
string oauth_config = 8; // empty string represents null
|
||||
}
|
||||
|
||||
// Each artifact collection on DeployArtifactsCommand is a NULLABLE list, and
|
||||
// proto3 `repeated` cannot distinguish null from empty. Wrapping each in its
|
||||
// own message makes the null/empty distinction a message-presence question,
|
||||
// which proto3 does model — the same silent-data-loss class the transport
|
||||
// round-trip guard caught in PLAN-05 T8.
|
||||
message SharedScriptArtifactListDto { repeated SharedScriptArtifactDto items = 1; }
|
||||
message ExternalSystemArtifactListDto { repeated ExternalSystemArtifactDto items = 1; }
|
||||
message DatabaseConnectionArtifactListDto { repeated DatabaseConnectionArtifactDto items = 1; }
|
||||
message NotificationListArtifactListDto { repeated NotificationListArtifactDto items = 1; }
|
||||
message DataConnectionArtifactListDto { repeated DataConnectionArtifactDto items = 1; }
|
||||
message SmtpConfigurationArtifactListDto { repeated SmtpConfigurationArtifactDto items = 1; }
|
||||
|
||||
message DeployArtifactsCommandDto {
|
||||
string deployment_id = 1;
|
||||
SharedScriptArtifactListDto shared_scripts = 2; // absent => null
|
||||
ExternalSystemArtifactListDto external_systems = 3; // absent => null
|
||||
DatabaseConnectionArtifactListDto database_connections = 4; // absent => null
|
||||
NotificationListArtifactListDto notification_lists = 5; // absent => null
|
||||
DataConnectionArtifactListDto data_connections = 6; // absent => null
|
||||
SmtpConfigurationArtifactListDto smtp_configurations = 7; // absent => null
|
||||
google.protobuf.Timestamp timestamp = 8;
|
||||
}
|
||||
|
||||
message DeploymentStatusResponseDto {
|
||||
string deployment_id = 1;
|
||||
string instance_unique_name = 2;
|
||||
DeploymentStatusDto status = 3;
|
||||
string error_message = 4; // empty string represents null
|
||||
google.protobuf.Timestamp timestamp = 5;
|
||||
}
|
||||
|
||||
message InstanceLifecycleResponseDto {
|
||||
string command_id = 1;
|
||||
string instance_unique_name = 2;
|
||||
bool success = 3;
|
||||
string error_message = 4; // empty string represents null
|
||||
google.protobuf.Timestamp timestamp = 5;
|
||||
}
|
||||
|
||||
message DeploymentStateQueryResponseDto {
|
||||
string correlation_id = 1;
|
||||
string instance_unique_name = 2;
|
||||
bool is_deployed = 3;
|
||||
string applied_deployment_id = 4; // empty string represents null
|
||||
string applied_revision_hash = 5; // empty string represents null
|
||||
google.protobuf.Timestamp timestamp = 6;
|
||||
}
|
||||
|
||||
message ArtifactDeploymentResponseDto {
|
||||
string deployment_id = 1;
|
||||
string site_id = 2;
|
||||
bool success = 3;
|
||||
string error_message = 4; // empty string represents null
|
||||
google.protobuf.Timestamp timestamp = 5;
|
||||
}
|
||||
|
||||
message LifecycleRequest {
|
||||
oneof command {
|
||||
RefreshDeploymentCommandDto refresh_deployment = 1;
|
||||
EnableInstanceCommandDto enable_instance = 2;
|
||||
DisableInstanceCommandDto disable_instance = 3;
|
||||
DeleteInstanceCommandDto delete_instance = 4;
|
||||
DeploymentStateQueryRequestDto deployment_state_query = 5;
|
||||
DeployArtifactsCommandDto deploy_artifacts = 6;
|
||||
}
|
||||
}
|
||||
|
||||
message LifecycleReply {
|
||||
oneof reply {
|
||||
DeploymentStatusResponseDto deployment_status = 1;
|
||||
InstanceLifecycleResponseDto instance_lifecycle = 2;
|
||||
DeploymentStateQueryResponseDto deployment_state_query = 3;
|
||||
ArtifactDeploymentResponseDto artifact_deployment = 4;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// RPC 2 — ExecuteOpcUa
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
message BrowseNodeCommandDto {
|
||||
string connection_name = 1;
|
||||
string parent_node_id = 2; // empty string represents null (browse root)
|
||||
string continuation_token = 3; // empty string represents null (first page)
|
||||
string site_identifier = 4; // empty string represents null
|
||||
}
|
||||
|
||||
message BrowseNodeDto {
|
||||
string node_id = 1;
|
||||
string display_name = 2;
|
||||
BrowseNodeClassDto node_class = 3;
|
||||
bool has_children = 4;
|
||||
string data_type = 5; // empty string represents null
|
||||
google.protobuf.Int32Value value_rank = 6; // absent => null
|
||||
google.protobuf.BoolValue writable = 7; // absent => null
|
||||
}
|
||||
|
||||
message BrowseFailureDto {
|
||||
BrowseFailureKindDto kind = 1;
|
||||
string message = 2;
|
||||
}
|
||||
|
||||
message BrowseNodeResultDto {
|
||||
repeated BrowseNodeDto children = 1;
|
||||
bool truncated = 2;
|
||||
BrowseFailureDto failure = 3; // absent => null (success)
|
||||
string continuation_token = 4; // empty string represents null (final page)
|
||||
}
|
||||
|
||||
message SearchAddressSpaceCommandDto {
|
||||
string connection_name = 1;
|
||||
string query = 2;
|
||||
int32 max_depth = 3;
|
||||
int32 max_results = 4;
|
||||
string site_identifier = 5; // empty string represents null
|
||||
}
|
||||
|
||||
message AddressSpaceMatchDto {
|
||||
BrowseNodeDto node = 1;
|
||||
string path = 2;
|
||||
}
|
||||
|
||||
message SearchAddressSpaceResultDto {
|
||||
repeated AddressSpaceMatchDto matches = 1;
|
||||
bool cap_reached = 2;
|
||||
BrowseFailureDto failure = 3; // absent => null (success)
|
||||
}
|
||||
|
||||
message ReadTagValuesCommandDto {
|
||||
string connection_name = 1;
|
||||
repeated string tag_paths = 2;
|
||||
}
|
||||
|
||||
message TagReadOutcomeDto {
|
||||
string tag_path = 1;
|
||||
bool success = 2;
|
||||
LooseValue value = 3; // absent => null
|
||||
string quality = 4;
|
||||
google.protobuf.Timestamp timestamp = 5;
|
||||
string error_message = 6; // empty string represents null
|
||||
}
|
||||
|
||||
message ReadTagValuesFailureDto {
|
||||
ReadTagValuesFailureKindDto kind = 1;
|
||||
string message = 2;
|
||||
}
|
||||
|
||||
message ReadTagValuesResultDto {
|
||||
repeated TagReadOutcomeDto outcomes = 1;
|
||||
ReadTagValuesFailureDto failure = 2; // absent => null (success)
|
||||
}
|
||||
|
||||
message VerifyEndpointCommandDto {
|
||||
string connection_name = 1;
|
||||
string protocol = 2;
|
||||
string config_json = 3;
|
||||
string site_identifier = 4; // empty string represents null
|
||||
}
|
||||
|
||||
message ServerCertInfoDto {
|
||||
string thumbprint = 1;
|
||||
string subject = 2;
|
||||
string issuer = 3;
|
||||
google.protobuf.Timestamp not_before_utc = 4;
|
||||
google.protobuf.Timestamp not_after_utc = 5;
|
||||
string der_base64 = 6;
|
||||
}
|
||||
|
||||
// failure_kind is a NULLABLE enum on the CLR side, and proto3 enums have no
|
||||
// presence, so it rides inside a one-field message exactly like Int32Value.
|
||||
message VerifyFailureKindValue { VerifyFailureKindDto value = 1; }
|
||||
|
||||
message VerifyEndpointResultDto {
|
||||
bool success = 1;
|
||||
VerifyFailureKindValue failure_kind = 2; // absent => null (success)
|
||||
string error = 3; // empty string represents null
|
||||
ServerCertInfoDto cert = 4; // absent => null
|
||||
}
|
||||
|
||||
message TrustServerCertCommandDto {
|
||||
string connection_name = 1;
|
||||
string der_base64 = 2;
|
||||
string thumbprint = 3;
|
||||
string site_identifier = 4; // empty string represents null
|
||||
}
|
||||
|
||||
message ListServerCertsCommandDto {
|
||||
string site_identifier = 1; // empty string represents null
|
||||
}
|
||||
|
||||
message RemoveServerCertCommandDto {
|
||||
string thumbprint = 1;
|
||||
string site_identifier = 2; // empty string represents null
|
||||
}
|
||||
|
||||
message TrustedCertInfoDto {
|
||||
string thumbprint = 1;
|
||||
string subject = 2;
|
||||
string issuer = 3;
|
||||
google.protobuf.Timestamp not_before_utc = 4;
|
||||
google.protobuf.Timestamp not_after_utc = 5;
|
||||
bool rejected = 6;
|
||||
}
|
||||
|
||||
// CertTrustResult.Certs is a nullable list (null for trust/remove, populated
|
||||
// for list) — same null-vs-empty problem as the artifact collections.
|
||||
message TrustedCertInfoListDto { repeated TrustedCertInfoDto items = 1; }
|
||||
|
||||
message CertTrustResultDto {
|
||||
bool success = 1;
|
||||
string error = 2; // empty string represents null
|
||||
TrustedCertInfoListDto certs = 3; // absent => null
|
||||
}
|
||||
|
||||
message WriteTagRequestDto {
|
||||
string correlation_id = 1;
|
||||
string connection_name = 2;
|
||||
string tag_path = 3;
|
||||
LooseValue value = 4; // absent => null
|
||||
google.protobuf.Timestamp timestamp = 5;
|
||||
}
|
||||
|
||||
message WriteTagResponseDto {
|
||||
string correlation_id = 1;
|
||||
bool success = 2;
|
||||
string error_message = 3; // empty string represents null
|
||||
google.protobuf.Timestamp timestamp = 4;
|
||||
}
|
||||
|
||||
message OpcUaRequest {
|
||||
oneof command {
|
||||
BrowseNodeCommandDto browse_node = 1;
|
||||
SearchAddressSpaceCommandDto search_address_space = 2;
|
||||
ReadTagValuesCommandDto read_tag_values = 3;
|
||||
VerifyEndpointCommandDto verify_endpoint = 4;
|
||||
TrustServerCertCommandDto trust_server_cert = 5;
|
||||
ListServerCertsCommandDto list_server_certs = 6;
|
||||
RemoveServerCertCommandDto remove_server_cert = 7;
|
||||
WriteTagRequestDto write_tag = 8;
|
||||
}
|
||||
}
|
||||
|
||||
message OpcUaReply {
|
||||
oneof reply {
|
||||
BrowseNodeResultDto browse_node = 1;
|
||||
SearchAddressSpaceResultDto search_address_space = 2;
|
||||
ReadTagValuesResultDto read_tag_values = 3;
|
||||
VerifyEndpointResultDto verify_endpoint = 4;
|
||||
CertTrustResultDto cert_trust = 5;
|
||||
WriteTagResponseDto write_tag = 6;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// RPC 3 — ExecuteQuery
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
message EventLogQueryRequestDto {
|
||||
string correlation_id = 1;
|
||||
string site_id = 2;
|
||||
google.protobuf.Timestamp from = 3; // absent => null
|
||||
google.protobuf.Timestamp to = 4; // absent => null
|
||||
string event_type = 5; // empty string represents null
|
||||
string severity = 6; // empty string represents null
|
||||
string instance_id = 7; // empty string represents null
|
||||
string keyword_filter = 8; // empty string represents null
|
||||
string continuation_token = 9; // empty string represents null
|
||||
int32 page_size = 10;
|
||||
google.protobuf.Timestamp timestamp = 11;
|
||||
}
|
||||
|
||||
message EventLogEntryDto {
|
||||
string id = 1;
|
||||
google.protobuf.Timestamp timestamp = 2;
|
||||
string event_type = 3;
|
||||
string severity = 4;
|
||||
string instance_id = 5; // empty string represents null
|
||||
string source = 6;
|
||||
string message = 7;
|
||||
string details = 8; // empty string represents null
|
||||
}
|
||||
|
||||
message EventLogQueryResponseDto {
|
||||
string correlation_id = 1;
|
||||
string site_id = 2;
|
||||
repeated EventLogEntryDto entries = 3;
|
||||
string continuation_token = 4; // empty string represents null
|
||||
bool has_more = 5;
|
||||
bool success = 6;
|
||||
string error_message = 7; // empty string represents null
|
||||
google.protobuf.Timestamp timestamp = 8;
|
||||
}
|
||||
|
||||
message DebugSnapshotRequestDto {
|
||||
string instance_unique_name = 1;
|
||||
string correlation_id = 2;
|
||||
}
|
||||
|
||||
message SubscribeDebugViewRequestDto {
|
||||
string instance_unique_name = 1;
|
||||
string correlation_id = 2;
|
||||
}
|
||||
|
||||
message UnsubscribeDebugViewRequestDto {
|
||||
string instance_unique_name = 1;
|
||||
string correlation_id = 2;
|
||||
}
|
||||
|
||||
// UnsubscribeDebugView is a Tell today (CommunicationService.UnsubscribeDebugView
|
||||
// fires and forgets). A unary RPC must still answer something, so the site sends
|
||||
// this empty ack; the central transport ignores it to keep the caller-visible
|
||||
// fire-and-forget semantics identical.
|
||||
message UnsubscribeDebugViewAckDto {}
|
||||
|
||||
message AlarmConditionStateDto {
|
||||
bool active = 1;
|
||||
bool acknowledged = 2;
|
||||
google.protobuf.BoolValue confirmed = 3; // absent => null (not confirmable)
|
||||
AlarmShelveStateDto shelve = 4;
|
||||
bool suppressed = 5;
|
||||
int32 severity = 6;
|
||||
}
|
||||
|
||||
message DebugAttributeValueDto {
|
||||
string instance_unique_name = 1;
|
||||
string attribute_path = 2;
|
||||
string attribute_name = 3;
|
||||
LooseValue value = 4; // absent => null
|
||||
string quality = 5;
|
||||
google.protobuf.Timestamp timestamp = 6;
|
||||
}
|
||||
|
||||
// Full-fidelity projection of Commons AlarmStateChanged. Deliberately NOT the
|
||||
// sitestream AlarmStateUpdate: that one flattens the value to a display string
|
||||
// and the shelve state to free text, which is right for a live stream but would
|
||||
// lose data on a snapshot the central UI renders as authoritative state.
|
||||
message DebugAlarmStateDto {
|
||||
string instance_unique_name = 1;
|
||||
string alarm_name = 2;
|
||||
AlarmStateDto state = 3;
|
||||
int32 priority = 4;
|
||||
google.protobuf.Timestamp timestamp = 5;
|
||||
AlarmLevelDto level = 6;
|
||||
string message = 7;
|
||||
AlarmKindDto kind = 8;
|
||||
// Absent means "not explicitly set" — the CLR record then derives the
|
||||
// computed default from state + priority. See the mapper's note on why the
|
||||
// encoder omits a condition that already equals that derived default.
|
||||
AlarmConditionStateDto condition = 9;
|
||||
string source_reference = 10;
|
||||
string alarm_type_name = 11;
|
||||
string category = 12;
|
||||
string operator_user = 13;
|
||||
string operator_comment = 14;
|
||||
google.protobuf.Timestamp original_raise_time = 15; // absent => null
|
||||
string current_value = 16;
|
||||
string limit_value = 17;
|
||||
string native_source_canonical_name = 18;
|
||||
bool is_configured_placeholder = 19;
|
||||
}
|
||||
|
||||
message DebugViewSnapshotDto {
|
||||
string instance_unique_name = 1;
|
||||
repeated DebugAttributeValueDto attribute_values = 2;
|
||||
repeated DebugAlarmStateDto alarm_states = 3;
|
||||
google.protobuf.Timestamp snapshot_timestamp = 4;
|
||||
bool instance_not_found = 5;
|
||||
}
|
||||
|
||||
message QueryRequest {
|
||||
oneof command {
|
||||
EventLogQueryRequestDto event_log_query = 1;
|
||||
DebugSnapshotRequestDto debug_snapshot = 2;
|
||||
SubscribeDebugViewRequestDto subscribe_debug_view = 3;
|
||||
UnsubscribeDebugViewRequestDto unsubscribe_debug_view = 4;
|
||||
}
|
||||
}
|
||||
|
||||
message QueryReply {
|
||||
oneof reply {
|
||||
EventLogQueryResponseDto event_log_query = 1;
|
||||
DebugViewSnapshotDto debug_view_snapshot = 2;
|
||||
UnsubscribeDebugViewAckDto unsubscribe_debug_view = 3;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// RPC 4 — ExecuteParked
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
message ParkedMessageQueryRequestDto {
|
||||
string correlation_id = 1;
|
||||
string site_id = 2;
|
||||
int32 page_number = 3;
|
||||
int32 page_size = 4;
|
||||
google.protobuf.Timestamp timestamp = 5;
|
||||
}
|
||||
|
||||
message ParkedMessageEntryDto {
|
||||
string message_id = 1;
|
||||
string target_system = 2;
|
||||
string method_name = 3;
|
||||
string error_message = 4;
|
||||
int32 attempt_count = 5;
|
||||
google.protobuf.Timestamp original_timestamp = 6;
|
||||
google.protobuf.Timestamp last_attempt_timestamp = 7;
|
||||
int32 max_attempts = 8;
|
||||
StoreAndForwardCategoryDto category = 9;
|
||||
string origin_instance = 10; // empty string represents null
|
||||
}
|
||||
|
||||
message ParkedMessageQueryResponseDto {
|
||||
string correlation_id = 1;
|
||||
string site_id = 2;
|
||||
repeated ParkedMessageEntryDto messages = 3;
|
||||
int32 total_count = 4;
|
||||
int32 page_number = 5;
|
||||
int32 page_size = 6;
|
||||
bool success = 7;
|
||||
string error_message = 8; // empty string represents null
|
||||
google.protobuf.Timestamp timestamp = 9;
|
||||
}
|
||||
|
||||
message ParkedMessageRetryRequestDto {
|
||||
string correlation_id = 1;
|
||||
string site_id = 2;
|
||||
string message_id = 3;
|
||||
google.protobuf.Timestamp timestamp = 4;
|
||||
}
|
||||
|
||||
message ParkedMessageRetryResponseDto {
|
||||
string correlation_id = 1;
|
||||
bool success = 2;
|
||||
string error_message = 3; // empty string represents null
|
||||
}
|
||||
|
||||
message ParkedMessageDiscardRequestDto {
|
||||
string correlation_id = 1;
|
||||
string site_id = 2;
|
||||
string message_id = 3;
|
||||
google.protobuf.Timestamp timestamp = 4;
|
||||
}
|
||||
|
||||
message ParkedMessageDiscardResponseDto {
|
||||
string correlation_id = 1;
|
||||
bool success = 2;
|
||||
string error_message = 3; // empty string represents null
|
||||
}
|
||||
|
||||
message RetryParkedOperationDto {
|
||||
string correlation_id = 1;
|
||||
string tracked_operation_id = 2; // TrackedOperationId GUID, "D" format
|
||||
}
|
||||
|
||||
message DiscardParkedOperationDto {
|
||||
string correlation_id = 1;
|
||||
string tracked_operation_id = 2; // TrackedOperationId GUID, "D" format
|
||||
}
|
||||
|
||||
message ParkedOperationActionAckDto {
|
||||
string correlation_id = 1;
|
||||
bool applied = 2;
|
||||
string error_message = 3; // empty string represents null
|
||||
}
|
||||
|
||||
message ParkedRequest {
|
||||
oneof command {
|
||||
ParkedMessageQueryRequestDto parked_message_query = 1;
|
||||
ParkedMessageRetryRequestDto parked_message_retry = 2;
|
||||
ParkedMessageDiscardRequestDto parked_message_discard = 3;
|
||||
RetryParkedOperationDto retry_parked_operation = 4;
|
||||
DiscardParkedOperationDto discard_parked_operation = 5;
|
||||
}
|
||||
}
|
||||
|
||||
message ParkedReply {
|
||||
oneof reply {
|
||||
ParkedMessageQueryResponseDto parked_message_query = 1;
|
||||
ParkedMessageRetryResponseDto parked_message_retry = 2;
|
||||
ParkedMessageDiscardResponseDto parked_message_discard = 3;
|
||||
ParkedOperationActionAckDto parked_operation_action = 4;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// RPC 5 — ExecuteRoute
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
message RouteToCallRequestDto {
|
||||
string correlation_id = 1;
|
||||
string instance_unique_name = 2;
|
||||
string script_name = 3;
|
||||
LooseValueMap parameters = 4; // absent => null (distinct from empty)
|
||||
google.protobuf.Timestamp timestamp = 5;
|
||||
string parent_execution_id = 6; // empty string represents null
|
||||
}
|
||||
|
||||
message RouteToCallResponseDto {
|
||||
string correlation_id = 1;
|
||||
bool success = 2;
|
||||
LooseValue return_value = 3; // absent => null
|
||||
string error_message = 4; // empty string represents null
|
||||
google.protobuf.Timestamp timestamp = 5;
|
||||
}
|
||||
|
||||
message RouteToGetAttributesRequestDto {
|
||||
string correlation_id = 1;
|
||||
string instance_unique_name = 2;
|
||||
repeated string attribute_names = 3;
|
||||
google.protobuf.Timestamp timestamp = 4;
|
||||
string parent_execution_id = 5; // empty string represents null
|
||||
}
|
||||
|
||||
message RouteToGetAttributesResponseDto {
|
||||
string correlation_id = 1;
|
||||
LooseValueMap values = 2; // non-nullable on the CLR side; absent => empty
|
||||
bool success = 3;
|
||||
string error_message = 4; // empty string represents null
|
||||
google.protobuf.Timestamp timestamp = 5;
|
||||
}
|
||||
|
||||
message RouteToSetAttributesRequestDto {
|
||||
string correlation_id = 1;
|
||||
string instance_unique_name = 2;
|
||||
map<string, string> attribute_values = 3;
|
||||
google.protobuf.Timestamp timestamp = 4;
|
||||
string parent_execution_id = 5; // empty string represents null
|
||||
}
|
||||
|
||||
message RouteToSetAttributesResponseDto {
|
||||
string correlation_id = 1;
|
||||
bool success = 2;
|
||||
string error_message = 3; // empty string represents null
|
||||
google.protobuf.Timestamp timestamp = 4;
|
||||
}
|
||||
|
||||
message RouteToWaitForAttributeRequestDto {
|
||||
string correlation_id = 1;
|
||||
string instance_unique_name = 2;
|
||||
string attribute_name = 3;
|
||||
// NOT the empty-string-means-null convention: "wait for this attribute to
|
||||
// become the empty string" is a legitimate target that must stay distinct
|
||||
// from "no target supplied", so this one nullable string rides a wrapper.
|
||||
google.protobuf.StringValue target_value_encoded = 4;
|
||||
google.protobuf.Duration timeout = 5;
|
||||
google.protobuf.Timestamp timestamp = 6;
|
||||
string parent_execution_id = 7; // empty string represents null
|
||||
bool require_good_quality = 8;
|
||||
}
|
||||
|
||||
message RouteToWaitForAttributeResponseDto {
|
||||
string correlation_id = 1;
|
||||
bool matched = 2;
|
||||
LooseValue value = 3; // absent => null
|
||||
string quality = 4; // empty string represents null
|
||||
bool timed_out = 5;
|
||||
bool success = 6;
|
||||
string error_message = 7; // empty string represents null
|
||||
google.protobuf.Timestamp timestamp = 8;
|
||||
}
|
||||
|
||||
message RouteRequest {
|
||||
oneof command {
|
||||
RouteToCallRequestDto route_to_call = 1;
|
||||
RouteToGetAttributesRequestDto route_to_get_attributes = 2;
|
||||
RouteToSetAttributesRequestDto route_to_set_attributes = 3;
|
||||
RouteToWaitForAttributeRequestDto route_to_wait_for_attribute = 4;
|
||||
}
|
||||
}
|
||||
|
||||
message RouteReply {
|
||||
oneof reply {
|
||||
RouteToCallResponseDto route_to_call = 1;
|
||||
RouteToGetAttributesResponseDto route_to_get_attributes = 2;
|
||||
RouteToSetAttributesResponseDto route_to_set_attributes = 3;
|
||||
RouteToWaitForAttributeResponseDto route_to_wait_for_attribute = 4;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// RPC 6 — TriggerFailover
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
message TriggerSiteFailoverDto {
|
||||
string correlation_id = 1;
|
||||
string site_id = 2;
|
||||
}
|
||||
|
||||
message SiteFailoverAckDto {
|
||||
string correlation_id = 1;
|
||||
bool accepted = 2;
|
||||
string target_address = 3; // empty string represents null
|
||||
string error_message = 4; // empty string represents null
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,559 @@
|
||||
// <auto-generated>
|
||||
// Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
// source: Protos/site_command.proto
|
||||
// </auto-generated>
|
||||
#pragma warning disable 0414, 1591, 8981, 0612
|
||||
#region Designer generated code
|
||||
|
||||
using grpc = global::Grpc.Core;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
public static partial class SiteCommandService
|
||||
{
|
||||
static readonly string __ServiceName = "scadabridge.sitecommand.v1.SiteCommandService";
|
||||
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
static void __Helper_SerializeMessage(global::Google.Protobuf.IMessage message, grpc::SerializationContext context)
|
||||
{
|
||||
#if !GRPC_DISABLE_PROTOBUF_BUFFER_SERIALIZATION
|
||||
if (message is global::Google.Protobuf.IBufferMessage)
|
||||
{
|
||||
context.SetPayloadLength(message.CalculateSize());
|
||||
global::Google.Protobuf.MessageExtensions.WriteTo(message, context.GetBufferWriter());
|
||||
context.Complete();
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
context.Complete(global::Google.Protobuf.MessageExtensions.ToByteArray(message));
|
||||
}
|
||||
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
static class __Helper_MessageCache<T>
|
||||
{
|
||||
public static readonly bool IsBufferMessage = global::System.Reflection.IntrospectionExtensions.GetTypeInfo(typeof(global::Google.Protobuf.IBufferMessage)).IsAssignableFrom(typeof(T));
|
||||
}
|
||||
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
static T __Helper_DeserializeMessage<T>(grpc::DeserializationContext context, global::Google.Protobuf.MessageParser<T> parser) where T : global::Google.Protobuf.IMessage<T>
|
||||
{
|
||||
#if !GRPC_DISABLE_PROTOBUF_BUFFER_SERIALIZATION
|
||||
if (__Helper_MessageCache<T>.IsBufferMessage)
|
||||
{
|
||||
return parser.ParseFrom(context.PayloadAsReadOnlySequence());
|
||||
}
|
||||
#endif
|
||||
return parser.ParseFrom(context.PayloadAsNewBuffer());
|
||||
}
|
||||
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
static readonly grpc::Marshaller<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.LifecycleRequest> __Marshaller_scadabridge_sitecommand_v1_LifecycleRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.LifecycleRequest.Parser));
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
static readonly grpc::Marshaller<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.LifecycleReply> __Marshaller_scadabridge_sitecommand_v1_LifecycleReply = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.LifecycleReply.Parser));
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
static readonly grpc::Marshaller<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.OpcUaRequest> __Marshaller_scadabridge_sitecommand_v1_OpcUaRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.OpcUaRequest.Parser));
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
static readonly grpc::Marshaller<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.OpcUaReply> __Marshaller_scadabridge_sitecommand_v1_OpcUaReply = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.OpcUaReply.Parser));
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
static readonly grpc::Marshaller<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.QueryRequest> __Marshaller_scadabridge_sitecommand_v1_QueryRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.QueryRequest.Parser));
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
static readonly grpc::Marshaller<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.QueryReply> __Marshaller_scadabridge_sitecommand_v1_QueryReply = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.QueryReply.Parser));
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
static readonly grpc::Marshaller<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ParkedRequest> __Marshaller_scadabridge_sitecommand_v1_ParkedRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ParkedRequest.Parser));
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
static readonly grpc::Marshaller<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ParkedReply> __Marshaller_scadabridge_sitecommand_v1_ParkedReply = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ParkedReply.Parser));
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
static readonly grpc::Marshaller<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.RouteRequest> __Marshaller_scadabridge_sitecommand_v1_RouteRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.RouteRequest.Parser));
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
static readonly grpc::Marshaller<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.RouteReply> __Marshaller_scadabridge_sitecommand_v1_RouteReply = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.RouteReply.Parser));
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
static readonly grpc::Marshaller<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.TriggerSiteFailoverDto> __Marshaller_scadabridge_sitecommand_v1_TriggerSiteFailoverDto = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.TriggerSiteFailoverDto.Parser));
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
static readonly grpc::Marshaller<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteFailoverAckDto> __Marshaller_scadabridge_sitecommand_v1_SiteFailoverAckDto = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteFailoverAckDto.Parser));
|
||||
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
static readonly grpc::Method<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.LifecycleRequest, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.LifecycleReply> __Method_ExecuteLifecycle = new grpc::Method<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.LifecycleRequest, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.LifecycleReply>(
|
||||
grpc::MethodType.Unary,
|
||||
__ServiceName,
|
||||
"ExecuteLifecycle",
|
||||
__Marshaller_scadabridge_sitecommand_v1_LifecycleRequest,
|
||||
__Marshaller_scadabridge_sitecommand_v1_LifecycleReply);
|
||||
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
static readonly grpc::Method<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.OpcUaRequest, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.OpcUaReply> __Method_ExecuteOpcUa = new grpc::Method<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.OpcUaRequest, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.OpcUaReply>(
|
||||
grpc::MethodType.Unary,
|
||||
__ServiceName,
|
||||
"ExecuteOpcUa",
|
||||
__Marshaller_scadabridge_sitecommand_v1_OpcUaRequest,
|
||||
__Marshaller_scadabridge_sitecommand_v1_OpcUaReply);
|
||||
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
static readonly grpc::Method<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.QueryRequest, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.QueryReply> __Method_ExecuteQuery = new grpc::Method<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.QueryRequest, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.QueryReply>(
|
||||
grpc::MethodType.Unary,
|
||||
__ServiceName,
|
||||
"ExecuteQuery",
|
||||
__Marshaller_scadabridge_sitecommand_v1_QueryRequest,
|
||||
__Marshaller_scadabridge_sitecommand_v1_QueryReply);
|
||||
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
static readonly grpc::Method<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ParkedRequest, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ParkedReply> __Method_ExecuteParked = new grpc::Method<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ParkedRequest, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ParkedReply>(
|
||||
grpc::MethodType.Unary,
|
||||
__ServiceName,
|
||||
"ExecuteParked",
|
||||
__Marshaller_scadabridge_sitecommand_v1_ParkedRequest,
|
||||
__Marshaller_scadabridge_sitecommand_v1_ParkedReply);
|
||||
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
static readonly grpc::Method<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.RouteRequest, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.RouteReply> __Method_ExecuteRoute = new grpc::Method<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.RouteRequest, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.RouteReply>(
|
||||
grpc::MethodType.Unary,
|
||||
__ServiceName,
|
||||
"ExecuteRoute",
|
||||
__Marshaller_scadabridge_sitecommand_v1_RouteRequest,
|
||||
__Marshaller_scadabridge_sitecommand_v1_RouteReply);
|
||||
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
static readonly grpc::Method<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.TriggerSiteFailoverDto, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteFailoverAckDto> __Method_TriggerFailover = new grpc::Method<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.TriggerSiteFailoverDto, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteFailoverAckDto>(
|
||||
grpc::MethodType.Unary,
|
||||
__ServiceName,
|
||||
"TriggerFailover",
|
||||
__Marshaller_scadabridge_sitecommand_v1_TriggerSiteFailoverDto,
|
||||
__Marshaller_scadabridge_sitecommand_v1_SiteFailoverAckDto);
|
||||
|
||||
/// <summary>Service descriptor</summary>
|
||||
public static global::Google.Protobuf.Reflection.ServiceDescriptor Descriptor
|
||||
{
|
||||
get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteCommandReflection.Descriptor.Services[0]; }
|
||||
}
|
||||
|
||||
/// <summary>Base class for server-side implementations of SiteCommandService</summary>
|
||||
[grpc::BindServiceMethod(typeof(SiteCommandService), "BindService")]
|
||||
public abstract partial class SiteCommandServiceBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Deployment + instance lifecycle (6 commands).
|
||||
/// </summary>
|
||||
/// <param name="request">The request received from the client.</param>
|
||||
/// <param name="context">The context of the server-side call handler being invoked.</param>
|
||||
/// <returns>The response to send back to the client (wrapped by a task).</returns>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual global::System.Threading.Tasks.Task<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.LifecycleReply> ExecuteLifecycle(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.LifecycleRequest request, grpc::ServerCallContext context)
|
||||
{
|
||||
throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interactive OPC UA / MxGateway design-time commands (8 commands).
|
||||
/// </summary>
|
||||
/// <param name="request">The request received from the client.</param>
|
||||
/// <param name="context">The context of the server-side call handler being invoked.</param>
|
||||
/// <returns>The response to send back to the client (wrapped by a task).</returns>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual global::System.Threading.Tasks.Task<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.OpcUaReply> ExecuteOpcUa(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.OpcUaRequest request, grpc::ServerCallContext context)
|
||||
{
|
||||
throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remote read-only queries: event log + debug view (4 commands).
|
||||
/// </summary>
|
||||
/// <param name="request">The request received from the client.</param>
|
||||
/// <param name="context">The context of the server-side call handler being invoked.</param>
|
||||
/// <returns>The response to send back to the client (wrapped by a task).</returns>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual global::System.Threading.Tasks.Task<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.QueryReply> ExecuteQuery(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.QueryRequest request, grpc::ServerCallContext context)
|
||||
{
|
||||
throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parked store-and-forward message + cached-operation actions (5 commands).
|
||||
/// </summary>
|
||||
/// <param name="request">The request received from the client.</param>
|
||||
/// <param name="context">The context of the server-side call handler being invoked.</param>
|
||||
/// <returns>The response to send back to the client (wrapped by a task).</returns>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual global::System.Threading.Tasks.Task<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ParkedReply> ExecuteParked(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ParkedRequest request, grpc::ServerCallContext context)
|
||||
{
|
||||
throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inbound-API Route.To() relays (4 commands).
|
||||
/// </summary>
|
||||
/// <param name="request">The request received from the client.</param>
|
||||
/// <param name="context">The context of the server-side call handler being invoked.</param>
|
||||
/// <returns>The response to send back to the client (wrapped by a task).</returns>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual global::System.Threading.Tasks.Task<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.RouteReply> ExecuteRoute(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.RouteRequest request, grpc::ServerCallContext context)
|
||||
{
|
||||
throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Operator-initiated manual site-pair failover (1 command).
|
||||
/// </summary>
|
||||
/// <param name="request">The request received from the client.</param>
|
||||
/// <param name="context">The context of the server-side call handler being invoked.</param>
|
||||
/// <returns>The response to send back to the client (wrapped by a task).</returns>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual global::System.Threading.Tasks.Task<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteFailoverAckDto> TriggerFailover(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.TriggerSiteFailoverDto request, grpc::ServerCallContext context)
|
||||
{
|
||||
throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// <summary>Client for SiteCommandService</summary>
|
||||
public partial class SiteCommandServiceClient : grpc::ClientBase<SiteCommandServiceClient>
|
||||
{
|
||||
/// <summary>Creates a new client for SiteCommandService</summary>
|
||||
/// <param name="channel">The channel to use to make remote calls.</param>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public SiteCommandServiceClient(grpc::ChannelBase channel) : base(channel)
|
||||
{
|
||||
}
|
||||
/// <summary>Creates a new client for SiteCommandService that uses a custom <c>CallInvoker</c>.</summary>
|
||||
/// <param name="callInvoker">The callInvoker to use to make remote calls.</param>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public SiteCommandServiceClient(grpc::CallInvoker callInvoker) : base(callInvoker)
|
||||
{
|
||||
}
|
||||
/// <summary>Protected parameterless constructor to allow creation of test doubles.</summary>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
protected SiteCommandServiceClient() : base()
|
||||
{
|
||||
}
|
||||
/// <summary>Protected constructor to allow creation of configured clients.</summary>
|
||||
/// <param name="configuration">The client configuration.</param>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
protected SiteCommandServiceClient(ClientBaseConfiguration configuration) : base(configuration)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deployment + instance lifecycle (6 commands).
|
||||
/// </summary>
|
||||
/// <param name="request">The request to send to the server.</param>
|
||||
/// <param name="headers">The initial metadata to send with the call. This parameter is optional.</param>
|
||||
/// <param name="deadline">An optional deadline for the call. The call will be cancelled if deadline is hit.</param>
|
||||
/// <param name="cancellationToken">An optional token for canceling the call.</param>
|
||||
/// <returns>The response received from the server.</returns>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.LifecycleReply ExecuteLifecycle(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.LifecycleRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
|
||||
{
|
||||
return ExecuteLifecycle(request, new grpc::CallOptions(headers, deadline, cancellationToken));
|
||||
}
|
||||
/// <summary>
|
||||
/// Deployment + instance lifecycle (6 commands).
|
||||
/// </summary>
|
||||
/// <param name="request">The request to send to the server.</param>
|
||||
/// <param name="options">The options for the call.</param>
|
||||
/// <returns>The response received from the server.</returns>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.LifecycleReply ExecuteLifecycle(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.LifecycleRequest request, grpc::CallOptions options)
|
||||
{
|
||||
return CallInvoker.BlockingUnaryCall(__Method_ExecuteLifecycle, null, options, request);
|
||||
}
|
||||
/// <summary>
|
||||
/// Deployment + instance lifecycle (6 commands).
|
||||
/// </summary>
|
||||
/// <param name="request">The request to send to the server.</param>
|
||||
/// <param name="headers">The initial metadata to send with the call. This parameter is optional.</param>
|
||||
/// <param name="deadline">An optional deadline for the call. The call will be cancelled if deadline is hit.</param>
|
||||
/// <param name="cancellationToken">An optional token for canceling the call.</param>
|
||||
/// <returns>The call object.</returns>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual grpc::AsyncUnaryCall<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.LifecycleReply> ExecuteLifecycleAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.LifecycleRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
|
||||
{
|
||||
return ExecuteLifecycleAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken));
|
||||
}
|
||||
/// <summary>
|
||||
/// Deployment + instance lifecycle (6 commands).
|
||||
/// </summary>
|
||||
/// <param name="request">The request to send to the server.</param>
|
||||
/// <param name="options">The options for the call.</param>
|
||||
/// <returns>The call object.</returns>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual grpc::AsyncUnaryCall<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.LifecycleReply> ExecuteLifecycleAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.LifecycleRequest request, grpc::CallOptions options)
|
||||
{
|
||||
return CallInvoker.AsyncUnaryCall(__Method_ExecuteLifecycle, null, options, request);
|
||||
}
|
||||
/// <summary>
|
||||
/// Interactive OPC UA / MxGateway design-time commands (8 commands).
|
||||
/// </summary>
|
||||
/// <param name="request">The request to send to the server.</param>
|
||||
/// <param name="headers">The initial metadata to send with the call. This parameter is optional.</param>
|
||||
/// <param name="deadline">An optional deadline for the call. The call will be cancelled if deadline is hit.</param>
|
||||
/// <param name="cancellationToken">An optional token for canceling the call.</param>
|
||||
/// <returns>The response received from the server.</returns>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.OpcUaReply ExecuteOpcUa(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.OpcUaRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
|
||||
{
|
||||
return ExecuteOpcUa(request, new grpc::CallOptions(headers, deadline, cancellationToken));
|
||||
}
|
||||
/// <summary>
|
||||
/// Interactive OPC UA / MxGateway design-time commands (8 commands).
|
||||
/// </summary>
|
||||
/// <param name="request">The request to send to the server.</param>
|
||||
/// <param name="options">The options for the call.</param>
|
||||
/// <returns>The response received from the server.</returns>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.OpcUaReply ExecuteOpcUa(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.OpcUaRequest request, grpc::CallOptions options)
|
||||
{
|
||||
return CallInvoker.BlockingUnaryCall(__Method_ExecuteOpcUa, null, options, request);
|
||||
}
|
||||
/// <summary>
|
||||
/// Interactive OPC UA / MxGateway design-time commands (8 commands).
|
||||
/// </summary>
|
||||
/// <param name="request">The request to send to the server.</param>
|
||||
/// <param name="headers">The initial metadata to send with the call. This parameter is optional.</param>
|
||||
/// <param name="deadline">An optional deadline for the call. The call will be cancelled if deadline is hit.</param>
|
||||
/// <param name="cancellationToken">An optional token for canceling the call.</param>
|
||||
/// <returns>The call object.</returns>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual grpc::AsyncUnaryCall<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.OpcUaReply> ExecuteOpcUaAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.OpcUaRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
|
||||
{
|
||||
return ExecuteOpcUaAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken));
|
||||
}
|
||||
/// <summary>
|
||||
/// Interactive OPC UA / MxGateway design-time commands (8 commands).
|
||||
/// </summary>
|
||||
/// <param name="request">The request to send to the server.</param>
|
||||
/// <param name="options">The options for the call.</param>
|
||||
/// <returns>The call object.</returns>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual grpc::AsyncUnaryCall<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.OpcUaReply> ExecuteOpcUaAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.OpcUaRequest request, grpc::CallOptions options)
|
||||
{
|
||||
return CallInvoker.AsyncUnaryCall(__Method_ExecuteOpcUa, null, options, request);
|
||||
}
|
||||
/// <summary>
|
||||
/// Remote read-only queries: event log + debug view (4 commands).
|
||||
/// </summary>
|
||||
/// <param name="request">The request to send to the server.</param>
|
||||
/// <param name="headers">The initial metadata to send with the call. This parameter is optional.</param>
|
||||
/// <param name="deadline">An optional deadline for the call. The call will be cancelled if deadline is hit.</param>
|
||||
/// <param name="cancellationToken">An optional token for canceling the call.</param>
|
||||
/// <returns>The response received from the server.</returns>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.QueryReply ExecuteQuery(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.QueryRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
|
||||
{
|
||||
return ExecuteQuery(request, new grpc::CallOptions(headers, deadline, cancellationToken));
|
||||
}
|
||||
/// <summary>
|
||||
/// Remote read-only queries: event log + debug view (4 commands).
|
||||
/// </summary>
|
||||
/// <param name="request">The request to send to the server.</param>
|
||||
/// <param name="options">The options for the call.</param>
|
||||
/// <returns>The response received from the server.</returns>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.QueryReply ExecuteQuery(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.QueryRequest request, grpc::CallOptions options)
|
||||
{
|
||||
return CallInvoker.BlockingUnaryCall(__Method_ExecuteQuery, null, options, request);
|
||||
}
|
||||
/// <summary>
|
||||
/// Remote read-only queries: event log + debug view (4 commands).
|
||||
/// </summary>
|
||||
/// <param name="request">The request to send to the server.</param>
|
||||
/// <param name="headers">The initial metadata to send with the call. This parameter is optional.</param>
|
||||
/// <param name="deadline">An optional deadline for the call. The call will be cancelled if deadline is hit.</param>
|
||||
/// <param name="cancellationToken">An optional token for canceling the call.</param>
|
||||
/// <returns>The call object.</returns>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual grpc::AsyncUnaryCall<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.QueryReply> ExecuteQueryAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.QueryRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
|
||||
{
|
||||
return ExecuteQueryAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken));
|
||||
}
|
||||
/// <summary>
|
||||
/// Remote read-only queries: event log + debug view (4 commands).
|
||||
/// </summary>
|
||||
/// <param name="request">The request to send to the server.</param>
|
||||
/// <param name="options">The options for the call.</param>
|
||||
/// <returns>The call object.</returns>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual grpc::AsyncUnaryCall<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.QueryReply> ExecuteQueryAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.QueryRequest request, grpc::CallOptions options)
|
||||
{
|
||||
return CallInvoker.AsyncUnaryCall(__Method_ExecuteQuery, null, options, request);
|
||||
}
|
||||
/// <summary>
|
||||
/// Parked store-and-forward message + cached-operation actions (5 commands).
|
||||
/// </summary>
|
||||
/// <param name="request">The request to send to the server.</param>
|
||||
/// <param name="headers">The initial metadata to send with the call. This parameter is optional.</param>
|
||||
/// <param name="deadline">An optional deadline for the call. The call will be cancelled if deadline is hit.</param>
|
||||
/// <param name="cancellationToken">An optional token for canceling the call.</param>
|
||||
/// <returns>The response received from the server.</returns>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ParkedReply ExecuteParked(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ParkedRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
|
||||
{
|
||||
return ExecuteParked(request, new grpc::CallOptions(headers, deadline, cancellationToken));
|
||||
}
|
||||
/// <summary>
|
||||
/// Parked store-and-forward message + cached-operation actions (5 commands).
|
||||
/// </summary>
|
||||
/// <param name="request">The request to send to the server.</param>
|
||||
/// <param name="options">The options for the call.</param>
|
||||
/// <returns>The response received from the server.</returns>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ParkedReply ExecuteParked(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ParkedRequest request, grpc::CallOptions options)
|
||||
{
|
||||
return CallInvoker.BlockingUnaryCall(__Method_ExecuteParked, null, options, request);
|
||||
}
|
||||
/// <summary>
|
||||
/// Parked store-and-forward message + cached-operation actions (5 commands).
|
||||
/// </summary>
|
||||
/// <param name="request">The request to send to the server.</param>
|
||||
/// <param name="headers">The initial metadata to send with the call. This parameter is optional.</param>
|
||||
/// <param name="deadline">An optional deadline for the call. The call will be cancelled if deadline is hit.</param>
|
||||
/// <param name="cancellationToken">An optional token for canceling the call.</param>
|
||||
/// <returns>The call object.</returns>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual grpc::AsyncUnaryCall<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ParkedReply> ExecuteParkedAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ParkedRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
|
||||
{
|
||||
return ExecuteParkedAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken));
|
||||
}
|
||||
/// <summary>
|
||||
/// Parked store-and-forward message + cached-operation actions (5 commands).
|
||||
/// </summary>
|
||||
/// <param name="request">The request to send to the server.</param>
|
||||
/// <param name="options">The options for the call.</param>
|
||||
/// <returns>The call object.</returns>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual grpc::AsyncUnaryCall<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ParkedReply> ExecuteParkedAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ParkedRequest request, grpc::CallOptions options)
|
||||
{
|
||||
return CallInvoker.AsyncUnaryCall(__Method_ExecuteParked, null, options, request);
|
||||
}
|
||||
/// <summary>
|
||||
/// Inbound-API Route.To() relays (4 commands).
|
||||
/// </summary>
|
||||
/// <param name="request">The request to send to the server.</param>
|
||||
/// <param name="headers">The initial metadata to send with the call. This parameter is optional.</param>
|
||||
/// <param name="deadline">An optional deadline for the call. The call will be cancelled if deadline is hit.</param>
|
||||
/// <param name="cancellationToken">An optional token for canceling the call.</param>
|
||||
/// <returns>The response received from the server.</returns>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.RouteReply ExecuteRoute(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.RouteRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
|
||||
{
|
||||
return ExecuteRoute(request, new grpc::CallOptions(headers, deadline, cancellationToken));
|
||||
}
|
||||
/// <summary>
|
||||
/// Inbound-API Route.To() relays (4 commands).
|
||||
/// </summary>
|
||||
/// <param name="request">The request to send to the server.</param>
|
||||
/// <param name="options">The options for the call.</param>
|
||||
/// <returns>The response received from the server.</returns>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.RouteReply ExecuteRoute(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.RouteRequest request, grpc::CallOptions options)
|
||||
{
|
||||
return CallInvoker.BlockingUnaryCall(__Method_ExecuteRoute, null, options, request);
|
||||
}
|
||||
/// <summary>
|
||||
/// Inbound-API Route.To() relays (4 commands).
|
||||
/// </summary>
|
||||
/// <param name="request">The request to send to the server.</param>
|
||||
/// <param name="headers">The initial metadata to send with the call. This parameter is optional.</param>
|
||||
/// <param name="deadline">An optional deadline for the call. The call will be cancelled if deadline is hit.</param>
|
||||
/// <param name="cancellationToken">An optional token for canceling the call.</param>
|
||||
/// <returns>The call object.</returns>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual grpc::AsyncUnaryCall<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.RouteReply> ExecuteRouteAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.RouteRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
|
||||
{
|
||||
return ExecuteRouteAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken));
|
||||
}
|
||||
/// <summary>
|
||||
/// Inbound-API Route.To() relays (4 commands).
|
||||
/// </summary>
|
||||
/// <param name="request">The request to send to the server.</param>
|
||||
/// <param name="options">The options for the call.</param>
|
||||
/// <returns>The call object.</returns>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual grpc::AsyncUnaryCall<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.RouteReply> ExecuteRouteAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.RouteRequest request, grpc::CallOptions options)
|
||||
{
|
||||
return CallInvoker.AsyncUnaryCall(__Method_ExecuteRoute, null, options, request);
|
||||
}
|
||||
/// <summary>
|
||||
/// Operator-initiated manual site-pair failover (1 command).
|
||||
/// </summary>
|
||||
/// <param name="request">The request to send to the server.</param>
|
||||
/// <param name="headers">The initial metadata to send with the call. This parameter is optional.</param>
|
||||
/// <param name="deadline">An optional deadline for the call. The call will be cancelled if deadline is hit.</param>
|
||||
/// <param name="cancellationToken">An optional token for canceling the call.</param>
|
||||
/// <returns>The response received from the server.</returns>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteFailoverAckDto TriggerFailover(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.TriggerSiteFailoverDto request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
|
||||
{
|
||||
return TriggerFailover(request, new grpc::CallOptions(headers, deadline, cancellationToken));
|
||||
}
|
||||
/// <summary>
|
||||
/// Operator-initiated manual site-pair failover (1 command).
|
||||
/// </summary>
|
||||
/// <param name="request">The request to send to the server.</param>
|
||||
/// <param name="options">The options for the call.</param>
|
||||
/// <returns>The response received from the server.</returns>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteFailoverAckDto TriggerFailover(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.TriggerSiteFailoverDto request, grpc::CallOptions options)
|
||||
{
|
||||
return CallInvoker.BlockingUnaryCall(__Method_TriggerFailover, null, options, request);
|
||||
}
|
||||
/// <summary>
|
||||
/// Operator-initiated manual site-pair failover (1 command).
|
||||
/// </summary>
|
||||
/// <param name="request">The request to send to the server.</param>
|
||||
/// <param name="headers">The initial metadata to send with the call. This parameter is optional.</param>
|
||||
/// <param name="deadline">An optional deadline for the call. The call will be cancelled if deadline is hit.</param>
|
||||
/// <param name="cancellationToken">An optional token for canceling the call.</param>
|
||||
/// <returns>The call object.</returns>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual grpc::AsyncUnaryCall<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteFailoverAckDto> TriggerFailoverAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.TriggerSiteFailoverDto request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
|
||||
{
|
||||
return TriggerFailoverAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken));
|
||||
}
|
||||
/// <summary>
|
||||
/// Operator-initiated manual site-pair failover (1 command).
|
||||
/// </summary>
|
||||
/// <param name="request">The request to send to the server.</param>
|
||||
/// <param name="options">The options for the call.</param>
|
||||
/// <returns>The call object.</returns>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual grpc::AsyncUnaryCall<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteFailoverAckDto> TriggerFailoverAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.TriggerSiteFailoverDto request, grpc::CallOptions options)
|
||||
{
|
||||
return CallInvoker.AsyncUnaryCall(__Method_TriggerFailover, null, options, request);
|
||||
}
|
||||
/// <summary>Creates a new instance of client from given <c>ClientBaseConfiguration</c>.</summary>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
protected override SiteCommandServiceClient NewInstance(ClientBaseConfiguration configuration)
|
||||
{
|
||||
return new SiteCommandServiceClient(configuration);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Creates service definition that can be registered with a server</summary>
|
||||
/// <param name="serviceImpl">An object implementing the server-side handling logic.</param>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public static grpc::ServerServiceDefinition BindService(SiteCommandServiceBase serviceImpl)
|
||||
{
|
||||
return grpc::ServerServiceDefinition.CreateBuilder()
|
||||
.AddMethod(__Method_ExecuteLifecycle, serviceImpl.ExecuteLifecycle)
|
||||
.AddMethod(__Method_ExecuteOpcUa, serviceImpl.ExecuteOpcUa)
|
||||
.AddMethod(__Method_ExecuteQuery, serviceImpl.ExecuteQuery)
|
||||
.AddMethod(__Method_ExecuteParked, serviceImpl.ExecuteParked)
|
||||
.AddMethod(__Method_ExecuteRoute, serviceImpl.ExecuteRoute)
|
||||
.AddMethod(__Method_TriggerFailover, serviceImpl.TriggerFailover).Build();
|
||||
}
|
||||
|
||||
/// <summary>Register service method with a service binder with or without implementation. Useful when customizing the service binding logic.
|
||||
/// Note: this method is part of an experimental API that can change or be removed without any prior notice.</summary>
|
||||
/// <param name="serviceBinder">Service methods will be bound by calling <c>AddMethod</c> on this object.</param>
|
||||
/// <param name="serviceImpl">An object implementing the server-side handling logic.</param>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public static void BindService(grpc::ServiceBinderBase serviceBinder, SiteCommandServiceBase serviceImpl)
|
||||
{
|
||||
serviceBinder.AddMethod(__Method_ExecuteLifecycle, serviceImpl == null ? null : new grpc::UnaryServerMethod<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.LifecycleRequest, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.LifecycleReply>(serviceImpl.ExecuteLifecycle));
|
||||
serviceBinder.AddMethod(__Method_ExecuteOpcUa, serviceImpl == null ? null : new grpc::UnaryServerMethod<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.OpcUaRequest, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.OpcUaReply>(serviceImpl.ExecuteOpcUa));
|
||||
serviceBinder.AddMethod(__Method_ExecuteQuery, serviceImpl == null ? null : new grpc::UnaryServerMethod<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.QueryRequest, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.QueryReply>(serviceImpl.ExecuteQuery));
|
||||
serviceBinder.AddMethod(__Method_ExecuteParked, serviceImpl == null ? null : new grpc::UnaryServerMethod<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ParkedRequest, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ParkedReply>(serviceImpl.ExecuteParked));
|
||||
serviceBinder.AddMethod(__Method_ExecuteRoute, serviceImpl == null ? null : new grpc::UnaryServerMethod<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.RouteRequest, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.RouteReply>(serviceImpl.ExecuteRoute));
|
||||
serviceBinder.AddMethod(__Method_TriggerFailover, serviceImpl == null ? null : new grpc::UnaryServerMethod<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.TriggerSiteFailoverDto, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteFailoverAckDto>(serviceImpl.TriggerFailover));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
+16
-15
@@ -32,30 +32,31 @@
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.HealthMonitoring/ZB.MOM.WW.ScadaBridge.HealthMonitoring.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- gRPC proto generation. The compiled C# is checked in — SiteStreamGrpc/
|
||||
(Sitestream.cs + SitestreamGrpc.cs) for Protos/sitestream.proto, and
|
||||
<!-- gRPC proto generation. The compiled C# is checked in — under SiteStreamGrpc/
|
||||
(Sitestream.cs + SitestreamGrpc.cs) for Protos/sitestream.proto,
|
||||
CentralControlGrpc/ (CentralControl.cs + CentralControlGrpc.cs) for
|
||||
Protos/central_control.proto — because protoc segfaults inside our
|
||||
linux_arm64 Docker build image. To regenerate after schema changes run
|
||||
`docker/regen-proto.sh [sitestream|centralcontrol|all]`, which does all
|
||||
of the following and always leaves this file as it found it:
|
||||
1. Temporarily uncomment the Protobuf ItemGroup below (just the line
|
||||
for the proto you changed — the other file's checked-in C# is
|
||||
already compiled, so enabling both at once duplicates types).
|
||||
Protos/central_control.proto, and SiteCommandGrpc/ (SiteCommand.cs +
|
||||
SiteCommandGrpc.cs) for Protos/site_command.proto — because protoc segfaults
|
||||
inside our linux_arm64 Docker build image. To regenerate after schema changes
|
||||
run `docker/regen-proto.sh [sitestream|centralcontrol|sitecommand|all]`, which
|
||||
does all of the following and always leaves this file as it found it:
|
||||
1. Temporarily uncomment the Protobuf ItemGroup below (just the line for
|
||||
the proto you changed — the other files' checked-in C# is already
|
||||
compiled, so enabling several at once duplicates types).
|
||||
2. Delete the matching checked-in *.cs.
|
||||
3. `dotnet build` (on macOS) — Grpc.Tools writes fresh files to obj/.
|
||||
4. Copy obj/Debug/net10.0/Protos/*.cs into the matching folder.
|
||||
5. Re-comment the ItemGroup.
|
||||
central_control.proto imports sitestream.proto, so protoc resolves it
|
||||
from the project-relative path without sitestream.proto needing its own
|
||||
Protobuf item.
|
||||
An ACTIVE Protobuf item must never be committed — it breaks the Docker
|
||||
image build. Eventually we should switch the Docker build image to one
|
||||
with a working protoc on arm64. -->
|
||||
central_control.proto imports sitestream.proto, so protoc resolves it from the
|
||||
project-relative path without sitestream.proto needing its own Protobuf item.
|
||||
An ACTIVE Protobuf item must never be committed — it breaks the Docker image
|
||||
build. Eventually we should switch the Docker build image to one with a working
|
||||
protoc on arm64. -->
|
||||
<!--
|
||||
<ItemGroup>
|
||||
<Protobuf Include="Protos\sitestream.proto" GrpcServices="Both" />
|
||||
<Protobuf Include="Protos\central_control.proto" GrpcServices="Both" />
|
||||
<Protobuf Include="Protos\site_command.proto" GrpcServices="Both" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Goldens for <see cref="LooseValueCodec"/> — the type-tagged carrier for the
|
||||
/// <c>object?</c> members of the site command plane (script parameters and
|
||||
/// return values, attribute values, tag reads/writes).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The contract these tests pin down is that a boxed value keeps its RUNTIME
|
||||
/// CLR TYPE across the wire, not merely its printed form. Today's Akka JSON
|
||||
/// serializer preserves it, and an operator-facing tag value that silently
|
||||
/// turns from <c>int</c> into <c>long</c> (or from <c>DateTime</c> into a
|
||||
/// UTC-normalised copy) is a behaviour change, not a refactor.
|
||||
/// </remarks>
|
||||
public class LooseValueCodecTests
|
||||
{
|
||||
public static IEnumerable<object[]> TaggedScalars() =>
|
||||
[
|
||||
[true],
|
||||
[false],
|
||||
[42],
|
||||
[-1],
|
||||
[long.MaxValue],
|
||||
[3.5d],
|
||||
[1.5f],
|
||||
["a string"],
|
||||
[string.Empty],
|
||||
[12.3456789m],
|
||||
[new DateTime(2026, 7, 22, 1, 2, 3, DateTimeKind.Utc)],
|
||||
[new DateTimeOffset(2026, 7, 22, 1, 2, 3, TimeSpan.FromHours(-5))],
|
||||
[Guid.Parse("11111111-2222-3333-4444-555555555555")],
|
||||
[TimeSpan.FromMinutes(90)],
|
||||
[new byte[] { 1, 2, 3 }]
|
||||
];
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(TaggedScalars))]
|
||||
public void TaggedValues_KeepTheirClrType(object value)
|
||||
{
|
||||
var restored = LooseValueCodec.FromProto(LooseValueCodec.ToProto(value));
|
||||
|
||||
Assert.NotNull(restored);
|
||||
Assert.Equal(value.GetType(), restored.GetType());
|
||||
StructuralEquality.AssertDeepEqual(value, restored, value.GetType().Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A local <see cref="DateTime"/> must come back local, and an offset
|
||||
/// <see cref="DateTimeOffset"/> must come back with the same offset — the
|
||||
/// reason these ride as round-trip strings instead of proto timestamps.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DateValues_KeepKindAndOffset()
|
||||
{
|
||||
var local = new DateTime(2026, 7, 22, 1, 2, 3, DateTimeKind.Local);
|
||||
var offset = new DateTimeOffset(2026, 7, 22, 1, 2, 3, TimeSpan.FromHours(5.5));
|
||||
|
||||
var restoredLocal = Assert.IsType<DateTime>(LooseValueCodec.FromProto(LooseValueCodec.ToProto(local)));
|
||||
var restoredOffset =
|
||||
Assert.IsType<DateTimeOffset>(LooseValueCodec.FromProto(LooseValueCodec.ToProto(offset)));
|
||||
|
||||
Assert.Equal(DateTimeKind.Local, restoredLocal.Kind);
|
||||
Assert.Equal(local, restoredLocal);
|
||||
Assert.Equal(TimeSpan.FromHours(5.5), restoredOffset.Offset);
|
||||
}
|
||||
|
||||
/// <summary>An unset carrier and an explicit null marker both decode to null.</summary>
|
||||
[Fact]
|
||||
public void NullIsCarriedTwoWays()
|
||||
{
|
||||
Assert.Null(LooseValueCodec.FromProto(null));
|
||||
Assert.Null(LooseValueCodec.FromProto(LooseValueCodec.ToProto(null)));
|
||||
Assert.Null(LooseValueCodec.ToProtoOrNull(null));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A dictionary entry whose value is null stays distinct from an absent key —
|
||||
/// the reason <c>LooseNull</c> exists at all.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NullMapValue_SurvivesAsAPresentKey()
|
||||
{
|
||||
var original = new Dictionary<string, object?> { ["present"] = null };
|
||||
|
||||
var restored = LooseValueCodec.FromProtoMap(LooseValueCodec.ToProtoMap(original));
|
||||
|
||||
Assert.True(restored.ContainsKey("present"));
|
||||
Assert.Null(restored["present"]);
|
||||
}
|
||||
|
||||
/// <summary>A null dictionary stays null; an empty one stays empty.</summary>
|
||||
[Fact]
|
||||
public void NullMap_StaysDistinctFromEmptyMap()
|
||||
{
|
||||
Assert.Null(LooseValueCodec.ToProtoMapOrNull(null));
|
||||
Assert.Null(LooseValueCodec.FromProtoMapOrNull(null));
|
||||
|
||||
var empty = LooseValueCodec.FromProtoMapOrNull(
|
||||
LooseValueCodec.ToProtoMapOrNull(new Dictionary<string, object?>()));
|
||||
|
||||
Assert.NotNull(empty);
|
||||
Assert.Empty(empty);
|
||||
}
|
||||
|
||||
/// <summary>Nested lists and maps recurse, so element values keep their types too.</summary>
|
||||
[Fact]
|
||||
public void NestedCollections_KeepElementTypes()
|
||||
{
|
||||
object original = new List<object?>
|
||||
{
|
||||
1,
|
||||
"two",
|
||||
null,
|
||||
new Dictionary<string, object?> { ["deep"] = 3.5d }
|
||||
};
|
||||
|
||||
var restored = LooseValueCodec.FromProto(LooseValueCodec.ToProto(original));
|
||||
|
||||
StructuralEquality.AssertDeepEqual(original, restored, "list");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The documented widening: a typed collection keeps its ELEMENTS' types but
|
||||
/// the container comes back as <c>List<object?></c>. Recorded here so
|
||||
/// the behaviour is a decision rather than a surprise.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TypedList_WidensToObjectList()
|
||||
{
|
||||
var restored = LooseValueCodec.FromProto(LooseValueCodec.ToProto(new List<int> { 1, 2, 3 }));
|
||||
|
||||
var list = Assert.IsType<List<object?>>(restored);
|
||||
Assert.Equal([1, 2, 3], list.Cast<int>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The documented lossy escape hatch: a value outside the tagged set keeps its
|
||||
/// DATA but not its CLR identity — it decodes as a <see cref="JsonElement"/>.
|
||||
/// Nothing on the command plane carries such a value today; the fallback
|
||||
/// exists so an unexpected one cannot fault a command.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void UntaggedValue_FallsBackToJsonAndLosesItsClrType()
|
||||
{
|
||||
var restored = LooseValueCodec.FromProto(LooseValueCodec.ToProto((byte)7));
|
||||
|
||||
var element = Assert.IsType<JsonElement>(restored);
|
||||
Assert.Equal("7", element.GetRawText());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
using System.Reflection;
|
||||
using Google.Protobuf;
|
||||
using Google.Protobuf.Reflection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Round-trip goldens for <see cref="SiteCommandDtoMapper"/> — the 28 migrated
|
||||
/// central→site commands, all 22 reply shapes, and every nested type they
|
||||
/// carry, each proven to survive record → proto → record unchanged.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The suite is REFLECTION-DRIVEN on purpose. Both the per-type theory and the
|
||||
/// coverage guards enumerate the mapper's real surface (its <c>ToProto</c>
|
||||
/// overloads) and the real proto contract (the generated <c>oneof</c>
|
||||
/// descriptors), so adding a command without a golden fails the build rather
|
||||
/// than quietly shipping an untested field. A hand-maintained list across 50
|
||||
/// types would drift on the first change.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Where a round trip is not bit-exact, the mapper is the thing that gets fixed
|
||||
/// — never the fixture. The two deliberate normalisations
|
||||
/// (empty-string-means-null, and the implicit alarm <c>Condition</c>) are
|
||||
/// asserted explicitly below rather than papered over.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class SiteCommandDtoMapperGoldenTests
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, Type> TypesByName =
|
||||
SiteCommandSamples.All.Keys.ToDictionary(t => t.FullName!, t => t);
|
||||
|
||||
/// <summary>One theory case per (mapped type, golden sample).</summary>
|
||||
/// <returns>Type name and sample index pairs covering every golden fixture.</returns>
|
||||
public static IEnumerable<object[]> AllSamples() =>
|
||||
SiteCommandSamples.All
|
||||
.OrderBy(kv => kv.Key.FullName, StringComparer.Ordinal)
|
||||
.SelectMany(kv => Enumerable.Range(0, kv.Value.Length)
|
||||
.Select(i => new object[] { kv.Key.FullName!, i }));
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(AllSamples))]
|
||||
public void RoundTrips_Unchanged(string typeName, int sampleIndex)
|
||||
{
|
||||
var type = TypesByName[typeName];
|
||||
var original = SiteCommandSamples.All[type][sampleIndex];
|
||||
|
||||
var restored = RoundTrip(original, type);
|
||||
|
||||
StructuralEquality.AssertDeepEqual(original, restored, type.Name);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Coverage guards
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Every non-enum type the mapper can project must have goldens. This is the
|
||||
/// guard that makes the suite self-maintaining.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EveryMappedType_HasGoldenSamples()
|
||||
{
|
||||
var missing = MappedRecordTypes()
|
||||
.Where(t => !SiteCommandSamples.All.ContainsKey(t))
|
||||
.Select(t => t.Name)
|
||||
.OrderBy(n => n, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
Assert.True(
|
||||
missing.Count == 0,
|
||||
"SiteCommandDtoMapper projects types with no golden sample: " + string.Join(", ", missing));
|
||||
}
|
||||
|
||||
/// <summary>Every golden sample type must have at least two samples (populated + minimal).</summary>
|
||||
[Fact]
|
||||
public void EveryGoldenType_HasAtLeastOneSample()
|
||||
{
|
||||
var empty = SiteCommandSamples.All
|
||||
.Where(kv => kv.Value.Length == 0)
|
||||
.Select(kv => kv.Key.Name)
|
||||
.ToList();
|
||||
|
||||
Assert.True(empty.Count == 0, "Golden types with no samples: " + string.Join(", ", empty));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The contract carries exactly 28 commands — 29 on
|
||||
/// <c>SiteCommunicationActor</c>'s receive table minus the dead
|
||||
/// <c>IntegrationCallRequest</c>. If the site gains a command, this count
|
||||
/// moves deliberately, not silently.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CommandInventory_Is28_AcrossSixGroups()
|
||||
{
|
||||
var commands = CommandSampleTypes().ToList();
|
||||
|
||||
Assert.Equal(28, commands.Count);
|
||||
Assert.Equal(
|
||||
[6, 8, 4, 5, 4, 1],
|
||||
new[]
|
||||
{
|
||||
SiteCommandGroup.Lifecycle, SiteCommandGroup.OpcUa, SiteCommandGroup.Query,
|
||||
SiteCommandGroup.Parked, SiteCommandGroup.Route, SiteCommandGroup.Failover
|
||||
}.Select(g => commands.Count(t => SiteCommandDtoMapper.GroupOf(Sample(t)) == g)).ToArray());
|
||||
}
|
||||
|
||||
/// <summary>The contract carries 22 reply shapes across the same six groups.</summary>
|
||||
[Fact]
|
||||
public void ReplyInventory_Is22_AcrossSixGroups()
|
||||
{
|
||||
var replies = ReplyTypes().ToList();
|
||||
|
||||
Assert.Equal(22, replies.Count);
|
||||
Assert.Equal(
|
||||
[4, 6, 3, 4, 4, 1],
|
||||
new[]
|
||||
{
|
||||
SiteCommandGroup.Lifecycle, SiteCommandGroup.OpcUa, SiteCommandGroup.Query,
|
||||
SiteCommandGroup.Parked, SiteCommandGroup.Route, SiteCommandGroup.Failover
|
||||
}.Select(g => replies.Count(t => SiteCommandDtoMapper.GroupOfReply(SampleReply(t)) == g)).ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Packing every command golden must exercise EVERY <c>oneof</c> case
|
||||
/// declared in the four multi-command request envelopes. A new proto case
|
||||
/// with no producing command fails here.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(RequestEnvelopes))]
|
||||
public void EveryRequestOneofCase_IsProducedByACommand(string envelopeName)
|
||||
{
|
||||
var produced = CommandSampleTypes()
|
||||
.Select(t => PackCommand(Sample(t)))
|
||||
.OfType<IMessage>()
|
||||
.Where(m => m.Descriptor.Name == envelopeName)
|
||||
.Select(OneofCaseName)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var declared = DescriptorFor(envelopeName).Oneofs[0].Fields
|
||||
.Select(f => f.PropertyName)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
Assert.Equal(declared.OrderBy(n => n, StringComparer.Ordinal), produced.OrderBy(n => n, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>The reply-envelope mirror of <see cref="EveryRequestOneofCase_IsProducedByACommand"/>.</summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(ReplyEnvelopes))]
|
||||
public void EveryReplyOneofCase_IsProducedByAReply(string envelopeName)
|
||||
{
|
||||
var produced = ReplyTypes()
|
||||
.Select(t => PackReply(SampleReply(t)))
|
||||
.OfType<IMessage>()
|
||||
.Where(m => m.Descriptor.Name == envelopeName)
|
||||
.Select(OneofCaseName)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var declared = DescriptorFor(envelopeName).Oneofs[0].Fields
|
||||
.Select(f => f.PropertyName)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
Assert.Equal(declared.OrderBy(n => n, StringComparer.Ordinal), produced.OrderBy(n => n, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>Names of the four multi-command request envelopes.</summary>
|
||||
/// <returns>Envelope message names.</returns>
|
||||
public static IEnumerable<object[]> RequestEnvelopes() =>
|
||||
[["LifecycleRequest"], ["OpcUaRequest"], ["QueryRequest"], ["ParkedRequest"], ["RouteRequest"]];
|
||||
|
||||
/// <summary>Names of the four multi-reply reply envelopes.</summary>
|
||||
/// <returns>Envelope message names.</returns>
|
||||
public static IEnumerable<object[]> ReplyEnvelopes() =>
|
||||
[["LifecycleReply"], ["OpcUaReply"], ["QueryReply"], ["ParkedReply"], ["RouteReply"]];
|
||||
|
||||
/// <summary>Every command golden survives the full envelope pack/unpack, not just its own message.</summary>
|
||||
[Fact]
|
||||
public void EveryCommandGolden_SurvivesItsEnvelope()
|
||||
{
|
||||
foreach (var (type, samples) in SiteCommandSamples.All)
|
||||
{
|
||||
if (!IsCommand(type))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var sample in samples)
|
||||
{
|
||||
var restored = UnpackCommand(PackCommand(sample));
|
||||
StructuralEquality.AssertDeepEqual(sample, restored, $"{type.Name}(envelope)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Every reply golden survives the full envelope pack/unpack.</summary>
|
||||
[Fact]
|
||||
public void EveryReplyGolden_SurvivesItsEnvelope()
|
||||
{
|
||||
foreach (var (type, samples) in SiteCommandSamples.All)
|
||||
{
|
||||
if (!IsReply(type))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var sample in samples)
|
||||
{
|
||||
var restored = UnpackReply(PackReply(sample));
|
||||
StructuralEquality.AssertDeepEqual(sample, restored, $"{type.Name}(envelope)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The fire-and-forget unsubscribe ack is a real envelope case, not a special path.</summary>
|
||||
[Fact]
|
||||
public void UnsubscribeDebugViewAck_RoundTripsThroughQueryReply()
|
||||
{
|
||||
var envelope = SiteCommandDtoMapper.ToQueryReply(UnsubscribeDebugViewAck.Instance);
|
||||
|
||||
Assert.Equal(QueryReply.ReplyOneofCase.UnsubscribeDebugView, envelope.ReplyCase);
|
||||
Assert.Same(UnsubscribeDebugViewAck.Instance, SiteCommandDtoMapper.FromQueryReply(envelope));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Enum exhaustiveness
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(DeploymentStatus.Pending)]
|
||||
[InlineData(DeploymentStatus.InProgress)]
|
||||
[InlineData(DeploymentStatus.Success)]
|
||||
[InlineData(DeploymentStatus.Failed)]
|
||||
public void DeploymentStatus_RoundTrips(DeploymentStatus value) =>
|
||||
Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value)));
|
||||
|
||||
[Theory]
|
||||
[InlineData(BrowseNodeClass.Object)]
|
||||
[InlineData(BrowseNodeClass.Variable)]
|
||||
[InlineData(BrowseNodeClass.Method)]
|
||||
[InlineData(BrowseNodeClass.Other)]
|
||||
public void BrowseNodeClass_RoundTrips(BrowseNodeClass value) =>
|
||||
Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value)));
|
||||
|
||||
[Theory]
|
||||
[InlineData(BrowseFailureKind.ConnectionNotFound)]
|
||||
[InlineData(BrowseFailureKind.ConnectionNotConnected)]
|
||||
[InlineData(BrowseFailureKind.NotBrowsable)]
|
||||
[InlineData(BrowseFailureKind.Timeout)]
|
||||
[InlineData(BrowseFailureKind.ServerError)]
|
||||
public void BrowseFailureKind_RoundTrips(BrowseFailureKind value) =>
|
||||
Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value)));
|
||||
|
||||
[Theory]
|
||||
[InlineData(ReadTagValuesFailureKind.ConnectionNotFound)]
|
||||
[InlineData(ReadTagValuesFailureKind.ConnectionNotConnected)]
|
||||
[InlineData(ReadTagValuesFailureKind.Timeout)]
|
||||
[InlineData(ReadTagValuesFailureKind.ServerError)]
|
||||
public void ReadTagValuesFailureKind_RoundTrips(ReadTagValuesFailureKind value) =>
|
||||
Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value)));
|
||||
|
||||
[Theory]
|
||||
[InlineData(VerifyFailureKind.Unreachable)]
|
||||
[InlineData(VerifyFailureKind.AuthFailed)]
|
||||
[InlineData(VerifyFailureKind.UntrustedCertificate)]
|
||||
[InlineData(VerifyFailureKind.Timeout)]
|
||||
[InlineData(VerifyFailureKind.ServerError)]
|
||||
public void VerifyFailureKind_RoundTrips(VerifyFailureKind value) =>
|
||||
Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value)));
|
||||
|
||||
[Theory]
|
||||
[InlineData(StoreAndForwardCategory.ExternalSystem)]
|
||||
[InlineData(StoreAndForwardCategory.Notification)]
|
||||
[InlineData(StoreAndForwardCategory.CachedDbWrite)]
|
||||
public void StoreAndForwardCategory_RoundTrips(StoreAndForwardCategory value) =>
|
||||
Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value)));
|
||||
|
||||
[Theory]
|
||||
[InlineData(AlarmState.Active)]
|
||||
[InlineData(AlarmState.Normal)]
|
||||
public void AlarmState_RoundTrips(AlarmState value) =>
|
||||
Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value)));
|
||||
|
||||
[Theory]
|
||||
[InlineData(AlarmLevel.None)]
|
||||
[InlineData(AlarmLevel.Low)]
|
||||
[InlineData(AlarmLevel.LowLow)]
|
||||
[InlineData(AlarmLevel.High)]
|
||||
[InlineData(AlarmLevel.HighHigh)]
|
||||
public void AlarmLevel_RoundTrips(AlarmLevel value) =>
|
||||
Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value)));
|
||||
|
||||
[Theory]
|
||||
[InlineData(AlarmKind.Computed)]
|
||||
[InlineData(AlarmKind.NativeOpcUa)]
|
||||
[InlineData(AlarmKind.NativeMxAccess)]
|
||||
public void AlarmKind_RoundTrips(AlarmKind value) =>
|
||||
Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value)));
|
||||
|
||||
[Theory]
|
||||
[InlineData(AlarmShelveState.Unshelved)]
|
||||
[InlineData(AlarmShelveState.OneShotShelved)]
|
||||
[InlineData(AlarmShelveState.TimedShelved)]
|
||||
[InlineData(AlarmShelveState.PermanentShelved)]
|
||||
public void AlarmShelveState_RoundTrips(AlarmShelveState value) =>
|
||||
Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value)));
|
||||
|
||||
/// <summary>
|
||||
/// An unspecified wire enum — what a peer on an older contract sends — must
|
||||
/// decode to the documented safe default instead of faulting the command.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void UnspecifiedWireEnums_DecodeToSafeDefaults()
|
||||
{
|
||||
Assert.Equal(DeploymentStatus.Failed, SiteCommandDtoMapper.FromProto(DeploymentStatusDto.Unspecified));
|
||||
Assert.Equal(BrowseNodeClass.Other, SiteCommandDtoMapper.FromProto(BrowseNodeClassDto.Unspecified));
|
||||
Assert.Equal(BrowseFailureKind.ServerError, SiteCommandDtoMapper.FromProto(BrowseFailureKindDto.Unspecified));
|
||||
Assert.Equal(
|
||||
ReadTagValuesFailureKind.ServerError,
|
||||
SiteCommandDtoMapper.FromProto(ReadTagValuesFailureKindDto.Unspecified));
|
||||
Assert.Equal(VerifyFailureKind.ServerError, SiteCommandDtoMapper.FromProto(VerifyFailureKindDto.Unspecified));
|
||||
Assert.Equal(
|
||||
StoreAndForwardCategory.ExternalSystem,
|
||||
SiteCommandDtoMapper.FromProto(StoreAndForwardCategoryDto.Unspecified));
|
||||
Assert.Equal(AlarmState.Normal, SiteCommandDtoMapper.FromProto(AlarmStateDto.Unspecified));
|
||||
Assert.Equal(AlarmLevel.None, SiteCommandDtoMapper.FromProto(AlarmLevelDto.Unspecified));
|
||||
Assert.Equal(AlarmKind.Computed, SiteCommandDtoMapper.FromProto(AlarmKindDto.Unspecified));
|
||||
Assert.Equal(AlarmShelveState.Unshelved, SiteCommandDtoMapper.FromProto(AlarmShelveStateDto.Unspecified));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// The two deliberate normalisations, asserted rather than hidden
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Nullable strings ride as plain proto3 strings, so an empty one comes back
|
||||
/// as null. Documented in the mapper; asserted here so it stays a choice.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EmptyNullableString_NormalisesToNull()
|
||||
{
|
||||
var original = new SiteFailoverAck("corr", Accepted: false, TargetAddress: "", ErrorMessage: "");
|
||||
|
||||
var restored = SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(original));
|
||||
|
||||
Assert.Null(restored.TargetAddress);
|
||||
Assert.Null(restored.ErrorMessage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// …but NOT for a wait target, where the empty string is a real value. This
|
||||
/// is why that one field carries a <c>StringValue</c> wrapper.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EmptyWaitTarget_StaysDistinctFromNull()
|
||||
{
|
||||
var empty = SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(
|
||||
new RouteToWaitForAttributeRequest("c", "i", "a", "", TimeSpan.FromSeconds(1), DateTimeOffset.UtcNow)));
|
||||
var missing = SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(
|
||||
new RouteToWaitForAttributeRequest("c", "i", "a", null, TimeSpan.FromSeconds(1), DateTimeOffset.UtcNow)));
|
||||
|
||||
Assert.Equal(string.Empty, empty.TargetValueEncoded);
|
||||
Assert.Null(missing.TargetValueEncoded);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A computed alarm leaves <see cref="AlarmStateChanged.Condition"/> implicit
|
||||
/// (the record derives it from State + Priority). The encoder omits a
|
||||
/// condition that already equals that derived value, so the record comes back
|
||||
/// byte-for-byte equal — including under record equality, which compares the
|
||||
/// nullable backing field, not the property.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ComputedAlarm_KeepsItsImplicitCondition()
|
||||
{
|
||||
var original = new AlarmStateChanged("inst", "alarm", AlarmState.Active, 500, DateTimeOffset.UtcNow);
|
||||
|
||||
var dto = SiteCommandDtoMapper.ToProto(original);
|
||||
var restored = SiteCommandDtoMapper.FromProto(dto);
|
||||
|
||||
Assert.Null(dto.Condition);
|
||||
Assert.Equal(original, restored);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The flip side of the normalisation: a condition set EXPLICITLY to the value
|
||||
/// the record would have derived comes back implicit. The
|
||||
/// <see cref="AlarmStateChanged.Condition"/> value is identical — only the
|
||||
/// record's private "was it set?" bit differs, which no consumer can observe.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ExplicitConditionEqualToTheDerivedDefault_NormalisesToImplicit()
|
||||
{
|
||||
var original = new AlarmStateChanged("inst", "alarm", AlarmState.Normal, 100, DateTimeOffset.UtcNow)
|
||||
{
|
||||
Condition = AlarmConditionStateFactory.ForComputed(AlarmState.Normal, 100)
|
||||
};
|
||||
|
||||
var restored = SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(original));
|
||||
|
||||
Assert.Equal(original.Condition, restored.Condition);
|
||||
}
|
||||
|
||||
/// <summary>A native alarm's explicit, non-derived condition is carried verbatim.</summary>
|
||||
[Fact]
|
||||
public void NativeAlarm_KeepsItsExplicitCondition()
|
||||
{
|
||||
var condition = new AlarmConditionState(true, false, true, AlarmShelveState.PermanentShelved, true, 999);
|
||||
var original = new AlarmStateChanged("inst", "alarm", AlarmState.Active, 500, DateTimeOffset.UtcNow)
|
||||
{
|
||||
Kind = AlarmKind.NativeMxAccess,
|
||||
Condition = condition
|
||||
};
|
||||
|
||||
var dto = SiteCommandDtoMapper.ToProto(original);
|
||||
var restored = SiteCommandDtoMapper.FromProto(dto);
|
||||
|
||||
Assert.NotNull(dto.Condition);
|
||||
Assert.Equal(condition, restored.Condition);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Group classification + rejection
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary><c>IntegrationCallRequest</c> is excluded by design and must be rejected, not silently dropped.</summary>
|
||||
[Fact]
|
||||
public void IntegrationCallRequest_IsRejected()
|
||||
{
|
||||
var dead = new Commons.Messages.Integration.IntegrationCallRequest(
|
||||
"corr", "site-a", "Instance", "System", "Method",
|
||||
new Dictionary<string, object?>(), DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Throws<ArgumentException>(() => SiteCommandDtoMapper.GroupOf(dead));
|
||||
}
|
||||
|
||||
/// <summary>Packing a command into the wrong group's envelope is a hard error, not a silent no-op.</summary>
|
||||
[Fact]
|
||||
public void PackingIntoTheWrongGroup_Throws() =>
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
SiteCommandDtoMapper.ToLifecycleRequest(new DebugSnapshotRequest("inst", "corr")));
|
||||
|
||||
/// <summary>An envelope with no oneof set (a newer peer's unknown case) surfaces as a clear failure.</summary>
|
||||
[Fact]
|
||||
public void UnsetOneof_ThrowsNotSupported() =>
|
||||
Assert.Throws<NotSupportedException>(() => SiteCommandDtoMapper.FromLifecycleRequest(new LifecycleRequest()));
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Reflection plumbing
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
private static object RoundTrip(object original, Type type)
|
||||
{
|
||||
var toProto = typeof(SiteCommandDtoMapper)
|
||||
.GetMethods(BindingFlags.Public | BindingFlags.Static)
|
||||
.Single(m => m.Name == "ToProto" && m.GetParameters() is [{ } p] && p.ParameterType == type);
|
||||
|
||||
var wire = toProto.Invoke(null, [original])!;
|
||||
|
||||
var fromProto = typeof(SiteCommandDtoMapper)
|
||||
.GetMethods(BindingFlags.Public | BindingFlags.Static)
|
||||
.Single(m => m.Name == "FromProto"
|
||||
&& m.GetParameters() is [{ } p]
|
||||
&& p.ParameterType == wire.GetType());
|
||||
|
||||
return fromProto.Invoke(null, [wire])!;
|
||||
}
|
||||
|
||||
private static IEnumerable<Type> MappedRecordTypes() =>
|
||||
typeof(SiteCommandDtoMapper)
|
||||
.GetMethods(BindingFlags.Public | BindingFlags.Static)
|
||||
.Where(m => m.Name == "ToProto" && m.GetParameters().Length == 1)
|
||||
.Select(m => m.GetParameters()[0].ParameterType)
|
||||
.Where(t => !t.IsEnum)
|
||||
.Distinct();
|
||||
|
||||
private static IEnumerable<Type> CommandSampleTypes() =>
|
||||
SiteCommandSamples.All.Keys.Where(IsCommand);
|
||||
|
||||
/// <summary>
|
||||
/// Reply shapes = the mapper's own classification, plus the synthetic
|
||||
/// unsubscribe ack, which has no <c>ToProto</c> overload of its own.
|
||||
/// </summary>
|
||||
private static IEnumerable<Type> ReplyTypes() =>
|
||||
SiteCommandSamples.All.Keys.Where(IsReply).Append(typeof(UnsubscribeDebugViewAck));
|
||||
|
||||
private static bool IsCommand(Type type) => Classifies(type, SiteCommandDtoMapper.GroupOf);
|
||||
|
||||
private static bool IsReply(Type type) => Classifies(type, SiteCommandDtoMapper.GroupOfReply);
|
||||
|
||||
private static bool Classifies(Type type, Func<object, SiteCommandGroup> classify)
|
||||
{
|
||||
try
|
||||
{
|
||||
classify(SiteCommandSamples.All[type][0]);
|
||||
return true;
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static object Sample(Type type) => SiteCommandSamples.All[type][0];
|
||||
|
||||
private static object SampleReply(Type type) =>
|
||||
type == typeof(UnsubscribeDebugViewAck) ? UnsubscribeDebugViewAck.Instance : Sample(type);
|
||||
|
||||
private static object PackCommand(object command) => SiteCommandDtoMapper.GroupOf(command) switch
|
||||
{
|
||||
SiteCommandGroup.Lifecycle => SiteCommandDtoMapper.ToLifecycleRequest(command),
|
||||
SiteCommandGroup.OpcUa => SiteCommandDtoMapper.ToOpcUaRequest(command),
|
||||
SiteCommandGroup.Query => SiteCommandDtoMapper.ToQueryRequest(command),
|
||||
SiteCommandGroup.Parked => SiteCommandDtoMapper.ToParkedRequest(command),
|
||||
SiteCommandGroup.Route => SiteCommandDtoMapper.ToRouteRequest(command),
|
||||
_ => SiteCommandDtoMapper.ToProto((TriggerSiteFailover)command)
|
||||
};
|
||||
|
||||
private static object UnpackCommand(object envelope) => envelope switch
|
||||
{
|
||||
LifecycleRequest r => SiteCommandDtoMapper.FromLifecycleRequest(r),
|
||||
OpcUaRequest r => SiteCommandDtoMapper.FromOpcUaRequest(r),
|
||||
QueryRequest r => SiteCommandDtoMapper.FromQueryRequest(r),
|
||||
ParkedRequest r => SiteCommandDtoMapper.FromParkedRequest(r),
|
||||
RouteRequest r => SiteCommandDtoMapper.FromRouteRequest(r),
|
||||
TriggerSiteFailoverDto d => SiteCommandDtoMapper.FromProto(d),
|
||||
_ => throw new InvalidOperationException($"Unknown request envelope {envelope.GetType().Name}.")
|
||||
};
|
||||
|
||||
private static object PackReply(object reply) => SiteCommandDtoMapper.GroupOfReply(reply) switch
|
||||
{
|
||||
SiteCommandGroup.Lifecycle => SiteCommandDtoMapper.ToLifecycleReply(reply),
|
||||
SiteCommandGroup.OpcUa => SiteCommandDtoMapper.ToOpcUaReply(reply),
|
||||
SiteCommandGroup.Query => SiteCommandDtoMapper.ToQueryReply(reply),
|
||||
SiteCommandGroup.Parked => SiteCommandDtoMapper.ToParkedReply(reply),
|
||||
SiteCommandGroup.Route => SiteCommandDtoMapper.ToRouteReply(reply),
|
||||
_ => SiteCommandDtoMapper.ToProto((SiteFailoverAck)reply)
|
||||
};
|
||||
|
||||
private static object UnpackReply(object envelope) => envelope switch
|
||||
{
|
||||
LifecycleReply r => SiteCommandDtoMapper.FromLifecycleReply(r),
|
||||
OpcUaReply r => SiteCommandDtoMapper.FromOpcUaReply(r),
|
||||
QueryReply r => SiteCommandDtoMapper.FromQueryReply(r),
|
||||
ParkedReply r => SiteCommandDtoMapper.FromParkedReply(r),
|
||||
RouteReply r => SiteCommandDtoMapper.FromRouteReply(r),
|
||||
SiteFailoverAckDto d => SiteCommandDtoMapper.FromProto(d),
|
||||
_ => throw new InvalidOperationException($"Unknown reply envelope {envelope.GetType().Name}.")
|
||||
};
|
||||
|
||||
private static MessageDescriptor DescriptorFor(string name) =>
|
||||
SiteCommandReflection.Descriptor.MessageTypes.Single(m => m.Name == name);
|
||||
|
||||
private static string OneofCaseName(IMessage envelope)
|
||||
{
|
||||
var oneof = envelope.Descriptor.Oneofs[0];
|
||||
var field = oneof.Accessor.GetCaseFieldDescriptor(envelope);
|
||||
Assert.NotNull(field);
|
||||
return field.PropertyName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Golden fixtures for the site-command round-trip suite: for every command,
|
||||
/// reply and nested type the mapper handles, at least one MAXIMALLY populated
|
||||
/// sample and one MINIMAL sample (every nullable null, every optional
|
||||
/// collection empty or absent).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The two-sample rule is the point. A single "typical" sample proves only that
|
||||
/// the happy path survives; the pairs are what catch a dropped optional field or
|
||||
/// a null/empty collapse — the class of silent data loss the transport
|
||||
/// round-trip guard exposed in PLAN-05 T8, which per-field unit tests had all
|
||||
/// missed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="SiteCommandDtoMapperGoldenTests"/> drives this table by
|
||||
/// reflection and FAILS when the mapper gains a type that has no sample here, so
|
||||
/// the coverage cannot silently drift as commands are added.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class SiteCommandSamples
|
||||
{
|
||||
private static readonly DateTimeOffset T1 = new(2026, 7, 22, 13, 45, 12, 345, TimeSpan.Zero);
|
||||
private static readonly DateTimeOffset T2 = new(2026, 1, 2, 3, 4, 5, TimeSpan.Zero);
|
||||
private static readonly DateTime D1 = new(2026, 3, 4, 5, 6, 7, DateTimeKind.Utc);
|
||||
private static readonly DateTime D2 = new(2027, 3, 4, 5, 6, 7, DateTimeKind.Utc);
|
||||
private static readonly Guid G1 = Guid.Parse("11111111-2222-3333-4444-555555555555");
|
||||
|
||||
/// <summary>
|
||||
/// Every CLR type the mapper round-trips, mapped to its golden samples.
|
||||
/// Enums are excluded — they get their own exhaustive test.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<Type, object[]> All { get; } = Build();
|
||||
|
||||
private static Dictionary<Type, object[]> Build()
|
||||
{
|
||||
var samples = new Dictionary<Type, object[]>();
|
||||
|
||||
void Add<T>(params T[] values) where T : notnull =>
|
||||
samples[typeof(T)] = values.Cast<object>().ToArray();
|
||||
|
||||
// ── Lifecycle commands ──
|
||||
Add(new RefreshDeploymentCommand(
|
||||
"dep-1", "Site1.Pump1", "hash-abc", "multi-role", T1, "http://central:5000", "tok-1"));
|
||||
|
||||
Add(new EnableInstanceCommand("cmd-1", "Site1.Pump1", T1));
|
||||
Add(new DisableInstanceCommand("cmd-2", "Site1.Pump1", T2));
|
||||
Add(new DeleteInstanceCommand("cmd-3", "Site1.Pump1", T1));
|
||||
Add(new DeploymentStateQueryRequest("corr-1", "Site1.Pump1", T1));
|
||||
|
||||
Add(
|
||||
// Full: every artifact collection populated.
|
||||
new DeployArtifactsCommand(
|
||||
"dep-2",
|
||||
[SharedScript(), new SharedScriptArtifact("s2", "return 2;", null, null)],
|
||||
[ExternalSystem()],
|
||||
[new DatabaseConnectionArtifact("db", "Server=x;", 3, TimeSpan.FromSeconds(7.5))],
|
||||
[new NotificationListArtifact("ops", ["a@x.com", "b@x.com"])],
|
||||
[DataConnection()],
|
||||
[Smtp()],
|
||||
T1),
|
||||
// Minimal: every collection NULL — must not come back as empty lists.
|
||||
new DeployArtifactsCommand("dep-3", null, null, null, null, null, null, T2),
|
||||
// Boundary: every collection present but EMPTY — must not come back null.
|
||||
new DeployArtifactsCommand("dep-4", [], [], [], [], [], [], T1));
|
||||
|
||||
Add(SharedScript(), new SharedScriptArtifact("bare", "", null, null));
|
||||
Add(ExternalSystem(), new ExternalSystemArtifact("bare", "http://x", "None", null, null, 0));
|
||||
Add(new DatabaseConnectionArtifact("db", "Server=x;", 3, TimeSpan.FromMilliseconds(1500)),
|
||||
new DatabaseConnectionArtifact("db2", "", 0, TimeSpan.Zero));
|
||||
Add(new NotificationListArtifact("ops", ["a@x.com"]), new NotificationListArtifact("empty", []));
|
||||
Add(DataConnection(), new DataConnectionArtifact("bare", "OpcUa", null, null, 0));
|
||||
Add(Smtp(), new SmtpConfigurationArtifact("bare", "smtp", 25, "None", "f@x.com", null, null, null));
|
||||
|
||||
// ── Lifecycle replies ──
|
||||
Add(new DeploymentStatusResponse("dep-1", "Site1.Pump1", DeploymentStatus.Success, null, T1),
|
||||
new DeploymentStatusResponse("dep-1", "Site1.Pump1", DeploymentStatus.Failed, "boom", T2));
|
||||
Add(new InstanceLifecycleResponse("cmd-1", "Site1.Pump1", true, null, T1),
|
||||
new InstanceLifecycleResponse("cmd-1", "Site1.Pump1", false, "nope", T2));
|
||||
Add(new DeploymentStateQueryResponse("corr-1", "Site1.Pump1", true, "dep-1", "hash-abc", T1),
|
||||
new DeploymentStateQueryResponse("corr-1", "Site1.Pump1", false, null, null, T2));
|
||||
Add(new ArtifactDeploymentResponse("dep-2", "site-a", true, null, T1),
|
||||
new ArtifactDeploymentResponse("dep-2", "site-a", false, "handler missing", T2));
|
||||
|
||||
// ── OPC UA commands ──
|
||||
Add(new BrowseNodeCommand("conn", "ns=2;s=Root", "cursor-1", "site-a"),
|
||||
new BrowseNodeCommand("conn", null, null, null));
|
||||
Add(new SearchAddressSpaceCommand("conn", "pump", 4, 200, "site-a"),
|
||||
new SearchAddressSpaceCommand("conn", "", 0, 0, null));
|
||||
Add(new ReadTagValuesCommand("conn", ["a", "b"]), new ReadTagValuesCommand("conn", []));
|
||||
Add(new VerifyEndpointCommand("conn", "OpcUa", "{\"url\":\"opc.tcp://x\"}", "site-a"),
|
||||
new VerifyEndpointCommand("conn", "OpcUa", "{}", null));
|
||||
Add(new TrustServerCertCommand("conn", "ZGVy", "AABB", "site-a"),
|
||||
new TrustServerCertCommand("conn", "ZGVy", "AABB", null));
|
||||
Add(new ListServerCertsCommand("site-a"), new ListServerCertsCommand(null));
|
||||
Add(new RemoveServerCertCommand("AABB", "site-a"), new RemoveServerCertCommand("AABB", null));
|
||||
Add(new WriteTagRequest("corr-2", "conn", "ns=2;s=Speed", 42.5d, T1),
|
||||
new WriteTagRequest("corr-2", "conn", "ns=2;s=Speed", null, T2),
|
||||
new WriteTagRequest("corr-2", "conn", "ns=2;s=Flag", true, T1),
|
||||
new WriteTagRequest("corr-2", "conn", "ns=2;s=Name", "manual", T1));
|
||||
|
||||
// ── OPC UA replies ──
|
||||
Add(new BrowseNodeResult([Node(), NodeBare()], true, null, "cursor-2"),
|
||||
new BrowseNodeResult([], false, new BrowseFailure(BrowseFailureKind.Timeout, "timed out"), null));
|
||||
Add(Node(), NodeBare());
|
||||
Add(new BrowseFailure(BrowseFailureKind.ConnectionNotConnected, "not connected"),
|
||||
new BrowseFailure(BrowseFailureKind.ServerError, ""));
|
||||
Add(new SearchAddressSpaceResult([new AddressSpaceMatch(Node(), "/Root/Pump1")], true, null),
|
||||
new SearchAddressSpaceResult([], false, new BrowseFailure(BrowseFailureKind.NotBrowsable, "no")));
|
||||
Add(new AddressSpaceMatch(Node(), "/Root/Pump1"), new AddressSpaceMatch(NodeBare(), ""));
|
||||
Add(new ReadTagValuesResult([Outcome(), OutcomeFailed()], null),
|
||||
new ReadTagValuesResult([], new ReadTagValuesFailure(ReadTagValuesFailureKind.ConnectionNotFound, "gone")));
|
||||
Add(Outcome(), OutcomeFailed());
|
||||
Add(new ReadTagValuesFailure(ReadTagValuesFailureKind.Timeout, "slow"),
|
||||
new ReadTagValuesFailure(ReadTagValuesFailureKind.ServerError, ""));
|
||||
Add(new VerifyEndpointResult(true, null, null, null),
|
||||
new VerifyEndpointResult(false, VerifyFailureKind.UntrustedCertificate, "untrusted", Cert()),
|
||||
new VerifyEndpointResult(false, VerifyFailureKind.Unreachable, "refused", null));
|
||||
Add(Cert());
|
||||
Add(new CertTrustResult(true, null, [Trusted(), TrustedRejected()]),
|
||||
new CertTrustResult(true, null, null),
|
||||
new CertTrustResult(false, "partial failure", []));
|
||||
Add(Trusted(), TrustedRejected());
|
||||
Add(new WriteTagResponse("corr-2", true, null, T1),
|
||||
new WriteTagResponse("corr-2", false, "denied", T2));
|
||||
|
||||
// ── Query commands ──
|
||||
Add(new EventLogQueryRequest(
|
||||
"corr-3", "site-a", T1, T2, "Lifecycle", "Warning", "Site1.Pump1", "restart", "cursor-3", 50, T1),
|
||||
new EventLogQueryRequest("corr-3", "site-a", null, null, null, null, null, null, null, 25, T2));
|
||||
Add(new DebugSnapshotRequest("Site1.Pump1", "corr-4"));
|
||||
Add(new SubscribeDebugViewRequest("Site1.Pump1", "corr-5"));
|
||||
Add(new UnsubscribeDebugViewRequest("Site1.Pump1", "corr-6"));
|
||||
|
||||
// ── Query replies ──
|
||||
Add(new EventLogQueryResponse("corr-3", "site-a", [Entry(), EntryBare()], "cursor-4", true, true, null, T1),
|
||||
new EventLogQueryResponse("corr-3", "site-a", [], null, false, false, "handler missing", T2));
|
||||
Add(Entry(), EntryBare());
|
||||
Add(new DebugViewSnapshot("Site1.Pump1", [AttrValue(), AttrValueNull()], [ComputedAlarm(), NativeAlarm()], T1),
|
||||
new DebugViewSnapshot("Site1.Pump1", [], [], T2, InstanceNotFound: true));
|
||||
Add(AttrValue(), AttrValueNull(), AttrValueList());
|
||||
Add(ComputedAlarm(), NativeAlarm());
|
||||
Add(new AlarmConditionState(true, false, true, AlarmShelveState.TimedShelved, true, 900),
|
||||
new AlarmConditionState(false, true, null, AlarmShelveState.Unshelved, false, 0));
|
||||
|
||||
// ── Parked commands ──
|
||||
Add(new ParkedMessageQueryRequest("corr-7", "site-a", 2, 25, T1));
|
||||
Add(new ParkedMessageRetryRequest("corr-8", "site-a", "msg-1", T1));
|
||||
Add(new ParkedMessageDiscardRequest("corr-9", "site-a", "msg-1", T2));
|
||||
Add(new RetryParkedOperation("corr-10", new TrackedOperationId(G1)),
|
||||
new RetryParkedOperation("corr-10", default));
|
||||
Add(new DiscardParkedOperation("corr-11", new TrackedOperationId(G1)),
|
||||
new DiscardParkedOperation("corr-11", default));
|
||||
|
||||
// ── Parked replies ──
|
||||
Add(new ParkedMessageQueryResponse("corr-7", "site-a", [Parked(), ParkedBare()], 2, 1, 25, true, null, T1),
|
||||
new ParkedMessageQueryResponse("corr-7", "site-a", [], 0, 1, 25, false, "handler missing", T2));
|
||||
Add(Parked(), ParkedBare());
|
||||
Add(new ParkedMessageRetryResponse("corr-8", true),
|
||||
new ParkedMessageRetryResponse("corr-8", false, "not parked"));
|
||||
Add(new ParkedMessageDiscardResponse("corr-9", true),
|
||||
new ParkedMessageDiscardResponse("corr-9", false, "not parked"));
|
||||
Add(new ParkedOperationActionAck("corr-10", true),
|
||||
new ParkedOperationActionAck("corr-10", false, "handler missing"));
|
||||
|
||||
// ── Route commands ──
|
||||
Add(new RouteToCallRequest("corr-12", "Site1.Pump1", "Start", Parameters(), T1, G1),
|
||||
new RouteToCallRequest("corr-12", "Site1.Pump1", "Start", null, T2),
|
||||
new RouteToCallRequest("corr-12", "Site1.Pump1", "Start", new Dictionary<string, object?>(), T1));
|
||||
Add(new RouteToGetAttributesRequest("corr-13", "Site1.Pump1", ["Speed", "Flow"], T1, G1),
|
||||
new RouteToGetAttributesRequest("corr-13", "Site1.Pump1", [], T2));
|
||||
Add(new RouteToSetAttributesRequest(
|
||||
"corr-14", "Site1.Pump1", new Dictionary<string, string> { ["Speed"] = "10" }, T1, G1),
|
||||
new RouteToSetAttributesRequest("corr-14", "Site1.Pump1", new Dictionary<string, string>(), T2));
|
||||
Add(new RouteToWaitForAttributeRequest(
|
||||
"corr-15", "Site1.Pump1", "Speed", "10", TimeSpan.FromSeconds(30), T1, G1, true),
|
||||
new RouteToWaitForAttributeRequest(
|
||||
"corr-15", "Site1.Pump1", "Speed", null, TimeSpan.Zero, T2),
|
||||
// "" is a legitimate wait target and must NOT collapse to null.
|
||||
new RouteToWaitForAttributeRequest(
|
||||
"corr-15", "Site1.Pump1", "Speed", "", TimeSpan.FromMinutes(1), T1));
|
||||
|
||||
// ── Route replies ──
|
||||
Add(new RouteToCallResponse("corr-12", true, 17L, null, T1),
|
||||
new RouteToCallResponse("corr-12", false, null, "script faulted", T2));
|
||||
Add(new RouteToGetAttributesResponse("corr-13", Parameters(), true, null, T1),
|
||||
new RouteToGetAttributesResponse("corr-13", new Dictionary<string, object?>(), false, "no instance", T2));
|
||||
Add(new RouteToSetAttributesResponse("corr-14", true, null, T1),
|
||||
new RouteToSetAttributesResponse("corr-14", false, "locked", T2));
|
||||
Add(new RouteToWaitForAttributeResponse("corr-15", true, 10, "Good", false, true, null, T1),
|
||||
new RouteToWaitForAttributeResponse("corr-15", false, null, null, true, true, null, T2));
|
||||
|
||||
// ── Failover ──
|
||||
Add(new TriggerSiteFailover("corr-16", "site-a"));
|
||||
Add(new SiteFailoverAck("corr-16", true, "akka.tcp://scadabridge@node-b:8081", null),
|
||||
new SiteFailoverAck("corr-16", false, null, "no standby available"));
|
||||
|
||||
return samples;
|
||||
}
|
||||
|
||||
private static SharedScriptArtifact SharedScript() =>
|
||||
new("Calc", "return 1;", "{\"p\":\"int\"}", "{\"r\":\"int\"}");
|
||||
|
||||
private static ExternalSystemArtifact ExternalSystem() =>
|
||||
new("Mes", "https://mes/api", "ApiKey", "{\"key\":\"x\"}", "[{\"name\":\"Post\"}]", 45);
|
||||
|
||||
private static DataConnectionArtifact DataConnection() =>
|
||||
new("Plc1", "OpcUa", "{\"url\":\"opc.tcp://a\"}", "{\"url\":\"opc.tcp://b\"}", 5);
|
||||
|
||||
private static SmtpConfigurationArtifact Smtp() =>
|
||||
new("Default", "smtp.host", 587, "Basic", "from@x.com", "user", "secret", "{\"tenant\":\"t\"}");
|
||||
|
||||
private static BrowseNode Node() => new("ns=2;s=Pump1.Speed", "Speed", BrowseNodeClass.Variable, false, "Double", -1, true);
|
||||
|
||||
private static BrowseNode NodeBare() => new("ns=2;s=Root", "Root", BrowseNodeClass.Object, true);
|
||||
|
||||
private static TagReadOutcome Outcome() => new("ns=2;s=Speed", true, 12.5d, "Good", T1, null);
|
||||
|
||||
private static TagReadOutcome OutcomeFailed() => new("ns=2;s=Bad", false, null, "Bad", T2, "no such node");
|
||||
|
||||
private static ServerCertInfo Cert() => new("AABB", "CN=server", "CN=issuer", D1, D2, "ZGVy");
|
||||
|
||||
private static TrustedCertInfo Trusted() => new("AABB", "CN=server", "CN=issuer", D1, D2, false);
|
||||
|
||||
private static TrustedCertInfo TrustedRejected() => new("CCDD", "CN=other", "CN=issuer", D1, D2, true);
|
||||
|
||||
private static EventLogEntry Entry() =>
|
||||
new(G1.ToString("D"), T1, "Lifecycle", "Warning", "Site1.Pump1", "InstanceActor", "restarted", "{\"n\":1}");
|
||||
|
||||
private static EventLogEntry EntryBare() =>
|
||||
new(Guid.Empty.ToString("D"), T2, "System", "Info", null, "Host", "started", null);
|
||||
|
||||
private static ParkedMessageEntry Parked() =>
|
||||
new("msg-1", "Mes", "Post", "500 from server", 7, T1, T2, 10, StoreAndForwardCategory.CachedDbWrite, "Site1.Pump1");
|
||||
|
||||
private static ParkedMessageEntry ParkedBare() =>
|
||||
new("msg-2", "Mes", "Post", "", 0, T2, T2);
|
||||
|
||||
private static AttributeValueChanged AttrValue() =>
|
||||
new("Site1.Pump1", "Pump1.Speed", "Speed", 12.5d, "Good", T1);
|
||||
|
||||
private static AttributeValueChanged AttrValueNull() =>
|
||||
new("Site1.Pump1", "Pump1.Speed", "Speed", null, "Bad", T2);
|
||||
|
||||
private static AttributeValueChanged AttrValueList() =>
|
||||
new("Site1.Pump1", "Pump1.Trend", "Trend", new List<object?> { 1, 2.5d, "x", null }, "Good", T1);
|
||||
|
||||
/// <summary>A computed alarm: <c>Condition</c> left implicit, which is the common case.</summary>
|
||||
private static AlarmStateChanged ComputedAlarm() =>
|
||||
new("Site1.Pump1", "HighSpeed", AlarmState.Active, 700, T1)
|
||||
{
|
||||
Level = AlarmLevel.HighHigh,
|
||||
Message = "Speed critically high"
|
||||
};
|
||||
|
||||
/// <summary>A mirrored native alarm: every native enrichment field populated, <c>Condition</c> explicit.</summary>
|
||||
private static AlarmStateChanged NativeAlarm() =>
|
||||
new("Site1.Pump1", "Tank01.Level.HiHi", AlarmState.Active, 850, T2)
|
||||
{
|
||||
Level = AlarmLevel.None,
|
||||
Message = "Level high",
|
||||
Kind = AlarmKind.NativeOpcUa,
|
||||
Condition = new AlarmConditionState(true, false, false, AlarmShelveState.OneShotShelved, true, 850),
|
||||
SourceReference = "Tank01.Level.HiHi",
|
||||
AlarmTypeName = "AnalogLimitAlarm.HiHi",
|
||||
Category = "Process",
|
||||
OperatorUser = "multi-role",
|
||||
OperatorComment = "ack'd at panel",
|
||||
OriginalRaiseTime = T1,
|
||||
CurrentValue = "91.2",
|
||||
LimitValue = "90.0",
|
||||
NativeSourceCanonicalName = "Tank01.TankAlarms",
|
||||
IsConfiguredPlaceholder = true
|
||||
};
|
||||
|
||||
private static Dictionary<string, object?> Parameters() => new()
|
||||
{
|
||||
["count"] = 3,
|
||||
["ratio"] = 1.25d,
|
||||
["name"] = "batch-1",
|
||||
["enabled"] = true,
|
||||
["missing"] = null,
|
||||
["when"] = T1,
|
||||
["id"] = G1,
|
||||
["items"] = new List<object?> { 1L, "two", null },
|
||||
["nested"] = new Dictionary<string, object?> { ["inner"] = 5f }
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using System.Collections;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Structural deep-equality assertion for the site-command round-trip goldens.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Record equality is NOT usable here. A C# record's generated <c>Equals</c>
|
||||
/// compares members with <c>EqualityComparer<T>.Default</c>, which for
|
||||
/// <c>IReadOnlyList<T></c> / <c>IReadOnlyDictionary<,></c> members
|
||||
/// degrades to reference equality — so <c>Assert.Equal(dto, roundTripped)</c>
|
||||
/// would fail on every collection-bearing message even when the mapper is
|
||||
/// perfect, and (worse) would pass vacuously nowhere useful. This walker
|
||||
/// compares by shape instead: dictionaries by key, sequences element-wise, and
|
||||
/// everything else property-by-property down to leaf values.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The failure message carries the full property path, so a dropped field
|
||||
/// reports as e.g. <c>DeployArtifactsCommand.ExternalSystems[0].TimeoutSeconds</c>
|
||||
/// rather than an opaque "objects differ".
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class StructuralEquality
|
||||
{
|
||||
/// <summary>Asserts two object graphs are structurally identical.</summary>
|
||||
/// <param name="expected">The original value.</param>
|
||||
/// <param name="actual">The value that came back through the mapper.</param>
|
||||
/// <param name="rootName">Name used as the root of the reported property path.</param>
|
||||
public static void AssertDeepEqual(object? expected, object? actual, string rootName)
|
||||
=> Compare(expected, actual, rootName);
|
||||
|
||||
private static void Compare(object? expected, object? actual, string path)
|
||||
{
|
||||
if (expected is null && actual is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (expected is null || actual is null)
|
||||
{
|
||||
Assert.Fail($"{path}: expected {Describe(expected)} but got {Describe(actual)}.");
|
||||
return;
|
||||
}
|
||||
|
||||
// JsonElement is the documented lossy escape hatch of LooseValueCodec; it
|
||||
// has no useful Equals, so compare the canonical JSON text.
|
||||
if (expected is JsonElement expectedJson && actual is JsonElement actualJson)
|
||||
{
|
||||
Assert.Equal(expectedJson.GetRawText(), actualJson.GetRawText());
|
||||
return;
|
||||
}
|
||||
|
||||
// Collections are compared by CONTENT, not by concrete container type.
|
||||
// Members are declared IReadOnlyList<T>/IReadOnlyDictionary<,>, so the
|
||||
// backing type (a collection-expression array here, a List<T> out of the
|
||||
// mapper) is not part of the contract. Element and value types below are
|
||||
// still compared strictly.
|
||||
if (expected is not string && actual is not string)
|
||||
{
|
||||
if (expected is IDictionary expectedMap && actual is IDictionary actualMap)
|
||||
{
|
||||
CompareDictionaries(expectedMap, actualMap, path);
|
||||
return;
|
||||
}
|
||||
|
||||
if (expected is IEnumerable expectedSeq && actual is IEnumerable actualSeq)
|
||||
{
|
||||
CompareSequences(expectedSeq, actualSeq, path);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var expectedType = expected.GetType();
|
||||
var actualType = actual.GetType();
|
||||
|
||||
if (expectedType != actualType)
|
||||
{
|
||||
Assert.Fail(
|
||||
$"{path}: type changed across the round trip — expected {expectedType.Name}, got {actualType.Name}.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsLeaf(expectedType))
|
||||
{
|
||||
Assert.True(
|
||||
Equals(expected, actual),
|
||||
$"{path}: expected '{expected}' but got '{actual}'.");
|
||||
return;
|
||||
}
|
||||
|
||||
CompareProperties(expected, actual, expectedType, path);
|
||||
}
|
||||
|
||||
private static void CompareDictionaries(IDictionary expected, IDictionary actual, string path)
|
||||
{
|
||||
Assert.True(
|
||||
expected.Count == actual.Count,
|
||||
$"{path}: entry count changed — expected {expected.Count}, got {actual.Count}.");
|
||||
|
||||
foreach (DictionaryEntry entry in expected)
|
||||
{
|
||||
Assert.True(
|
||||
actual.Contains(entry.Key),
|
||||
$"{path}: key '{entry.Key}' is missing after the round trip.");
|
||||
Compare(entry.Value, actual[entry.Key], $"{path}['{entry.Key}']");
|
||||
}
|
||||
}
|
||||
|
||||
private static void CompareSequences(IEnumerable expected, IEnumerable actual, string path)
|
||||
{
|
||||
var expectedItems = expected.Cast<object?>().ToList();
|
||||
var actualItems = actual.Cast<object?>().ToList();
|
||||
|
||||
Assert.True(
|
||||
expectedItems.Count == actualItems.Count,
|
||||
$"{path}: element count changed — expected {expectedItems.Count}, got {actualItems.Count}.");
|
||||
|
||||
for (var i = 0; i < expectedItems.Count; i++)
|
||||
{
|
||||
Compare(expectedItems[i], actualItems[i], $"{path}[{i}]");
|
||||
}
|
||||
}
|
||||
|
||||
private static void CompareProperties(object expected, object actual, Type type, string path)
|
||||
{
|
||||
var properties = type
|
||||
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(p => p.CanRead && p.GetIndexParameters().Length == 0)
|
||||
.ToList();
|
||||
|
||||
Assert.True(properties.Count > 0, $"{path}: {type.Name} exposes no readable properties to compare.");
|
||||
|
||||
foreach (var property in properties)
|
||||
{
|
||||
Compare(property.GetValue(expected), property.GetValue(actual), $"{path}.{property.Name}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsLeaf(Type type) =>
|
||||
type.IsPrimitive
|
||||
|| type.IsEnum
|
||||
|| type == typeof(string)
|
||||
|| type == typeof(decimal)
|
||||
|| type == typeof(DateTime)
|
||||
|| type == typeof(DateTimeOffset)
|
||||
|| type == typeof(TimeSpan)
|
||||
|| type == typeof(Guid)
|
||||
// Value types with no collection members (e.g. TrackedOperationId) have a
|
||||
// correct structural Equals of their own.
|
||||
|| (type.IsValueType && !type.IsGenericType);
|
||||
|
||||
private static string Describe(object? value) => value is null ? "<null>" : $"{value.GetType().Name}('{value}')";
|
||||
}
|
||||
Reference in New Issue
Block a user