feat: add sitestream.proto definition and generated gRPC stubs

Define the SiteStreamService proto for real-time instance event
streaming (attribute value changes, alarm state changes) from site
nodes to central. Add pre-generated C# stubs following the existing
LmxProxy pattern, gRPC NuGet packages with FrameworkReference for
ASP.NET Core server types, and proto roundtrip tests.
This commit is contained in:
Joseph Doherty
2026-03-21 11:37:39 -04:00
parent b76ce09221
commit 826cfbee31
5 changed files with 1729 additions and 4 deletions

View File

@@ -0,0 +1,52 @@
syntax = "proto3";
option csharp_namespace = "ScadaLink.Communication.Grpc";
package sitestream;
import "google/protobuf/timestamp.proto";
service SiteStreamService {
rpc SubscribeInstance(InstanceStreamRequest) returns (stream SiteStreamEvent);
}
message InstanceStreamRequest {
string correlation_id = 1;
string instance_unique_name = 2;
}
message SiteStreamEvent {
string correlation_id = 1;
oneof event {
AttributeValueUpdate attribute_changed = 2;
AlarmStateUpdate alarm_changed = 3;
}
}
enum Quality {
QUALITY_UNSPECIFIED = 0;
QUALITY_GOOD = 1;
QUALITY_UNCERTAIN = 2;
QUALITY_BAD = 3;
}
enum AlarmStateEnum {
ALARM_STATE_UNSPECIFIED = 0;
ALARM_STATE_NORMAL = 1;
ALARM_STATE_ACTIVE = 2;
}
message AttributeValueUpdate {
string instance_unique_name = 1;
string attribute_path = 2;
string attribute_name = 3;
string value = 4;
Quality quality = 5;
google.protobuf.Timestamp timestamp = 6;
}
message AlarmStateUpdate {
string instance_unique_name = 1;
string alarm_name = 2;
AlarmStateEnum state = 3;
int32 priority = 4;
google.protobuf.Timestamp timestamp = 5;
}

View File

@@ -7,15 +7,18 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Akka" Version="1.5.62" />
<PackageReference Include="Akka.Remote" Version="1.5.62" />
<PackageReference Include="Akka.Cluster" Version="1.5.62" />
<PackageReference Include="Akka.Cluster.Tools" Version="1.5.62" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.5" />
<PackageReference Include="Google.Protobuf" Version="3.29.3" />
<PackageReference Include="Grpc.Net.Client" Version="2.71.0" />
<PackageReference Include="Grpc.Tools" Version="2.71.0" PrivateAssets="All" />
</ItemGroup>
<ItemGroup>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,145 @@
// <auto-generated>
// Generated by the protocol buffer compiler. DO NOT EDIT!
// source: sitestream.proto
// </auto-generated>
#pragma warning disable 0414, 1591, 8981, 0612
#region Designer generated code
using grpc = global::Grpc.Core;
namespace ScadaLink.Communication.Grpc {
public static partial class SiteStreamService
{
static readonly string __ServiceName = "sitestream.SiteStreamService";
[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::ScadaLink.Communication.Grpc.InstanceStreamRequest> __Marshaller_sitestream_InstanceStreamRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ScadaLink.Communication.Grpc.InstanceStreamRequest.Parser));
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Marshaller<global::ScadaLink.Communication.Grpc.SiteStreamEvent> __Marshaller_sitestream_SiteStreamEvent = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ScadaLink.Communication.Grpc.SiteStreamEvent.Parser));
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Method<global::ScadaLink.Communication.Grpc.InstanceStreamRequest, global::ScadaLink.Communication.Grpc.SiteStreamEvent> __Method_SubscribeInstance = new grpc::Method<global::ScadaLink.Communication.Grpc.InstanceStreamRequest, global::ScadaLink.Communication.Grpc.SiteStreamEvent>(
grpc::MethodType.ServerStreaming,
__ServiceName,
"SubscribeInstance",
__Marshaller_sitestream_InstanceStreamRequest,
__Marshaller_sitestream_SiteStreamEvent);
/// <summary>Service descriptor</summary>
public static global::Google.Protobuf.Reflection.ServiceDescriptor Descriptor
{
get { return global::ScadaLink.Communication.Grpc.SitestreamReflection.Descriptor.Services[0]; }
}
/// <summary>Base class for server-side implementations of SiteStreamService</summary>
[grpc::BindServiceMethod(typeof(SiteStreamService), "BindService")]
public abstract partial class SiteStreamServiceBase
{
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual global::System.Threading.Tasks.Task SubscribeInstance(global::ScadaLink.Communication.Grpc.InstanceStreamRequest request, grpc::IServerStreamWriter<global::ScadaLink.Communication.Grpc.SiteStreamEvent> responseStream, grpc::ServerCallContext context)
{
throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
}
}
/// <summary>Client for SiteStreamService</summary>
public partial class SiteStreamServiceClient : grpc::ClientBase<SiteStreamServiceClient>
{
/// <summary>Creates a new client for SiteStreamService</summary>
/// <param name="channel">The channel to use to make remote calls.</param>
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public SiteStreamServiceClient(grpc::ChannelBase channel) : base(channel)
{
}
/// <summary>Creates a new client for SiteStreamService 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 SiteStreamServiceClient(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 SiteStreamServiceClient() : 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 SiteStreamServiceClient(ClientBaseConfiguration configuration) : base(configuration)
{
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual grpc::AsyncServerStreamingCall<global::ScadaLink.Communication.Grpc.SiteStreamEvent> SubscribeInstance(global::ScadaLink.Communication.Grpc.InstanceStreamRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
{
return SubscribeInstance(request, new grpc::CallOptions(headers, deadline, cancellationToken));
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual grpc::AsyncServerStreamingCall<global::ScadaLink.Communication.Grpc.SiteStreamEvent> SubscribeInstance(global::ScadaLink.Communication.Grpc.InstanceStreamRequest request, grpc::CallOptions options)
{
return CallInvoker.AsyncServerStreamingCall(__Method_SubscribeInstance, 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 SiteStreamServiceClient NewInstance(ClientBaseConfiguration configuration)
{
return new SiteStreamServiceClient(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(SiteStreamServiceBase serviceImpl)
{
return grpc::ServerServiceDefinition.CreateBuilder()
.AddMethod(__Method_SubscribeInstance, serviceImpl.SubscribeInstance).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, SiteStreamServiceBase serviceImpl)
{
serviceBinder.AddMethod(__Method_SubscribeInstance, serviceImpl == null ? null : new grpc::ServerStreamingServerMethod<global::ScadaLink.Communication.Grpc.InstanceStreamRequest, global::ScadaLink.Communication.Grpc.SiteStreamEvent>(serviceImpl.SubscribeInstance));
}
}
}
#endregion

View File

@@ -0,0 +1,129 @@
using Google.Protobuf;
using Google.Protobuf.WellKnownTypes;
using ScadaLink.Communication.Grpc;
namespace ScadaLink.Communication.Tests.Grpc;
public class ProtoRoundtripTests
{
[Theory]
[InlineData(Quality.Good)]
[InlineData(Quality.Uncertain)]
[InlineData(Quality.Bad)]
[InlineData(Quality.Unspecified)]
public void AttributeValueUpdate_RoundTrip(Quality quality)
{
var timestamp = Timestamp.FromDateTimeOffset(
new DateTimeOffset(2026, 3, 21, 12, 0, 0, TimeSpan.Zero));
var original = new AttributeValueUpdate
{
InstanceUniqueName = "Site1.Pump01",
AttributePath = "Modules.PressureModule",
AttributeName = "CurrentPressure",
Value = "42.5",
Quality = quality,
Timestamp = timestamp
};
var bytes = original.ToByteArray();
var deserialized = AttributeValueUpdate.Parser.ParseFrom(bytes);
Assert.Equal(original.InstanceUniqueName, deserialized.InstanceUniqueName);
Assert.Equal(original.AttributePath, deserialized.AttributePath);
Assert.Equal(original.AttributeName, deserialized.AttributeName);
Assert.Equal(original.Value, deserialized.Value);
Assert.Equal(original.Quality, deserialized.Quality);
Assert.Equal(original.Timestamp, deserialized.Timestamp);
Assert.Equal(timestamp.Seconds, deserialized.Timestamp.Seconds);
Assert.Equal(timestamp.Nanos, deserialized.Timestamp.Nanos);
}
[Theory]
[InlineData(AlarmStateEnum.AlarmStateNormal)]
[InlineData(AlarmStateEnum.AlarmStateActive)]
[InlineData(AlarmStateEnum.AlarmStateUnspecified)]
public void AlarmStateUpdate_RoundTrip(AlarmStateEnum state)
{
var timestamp = Timestamp.FromDateTimeOffset(
new DateTimeOffset(2026, 3, 21, 12, 30, 0, TimeSpan.Zero));
var original = new AlarmStateUpdate
{
InstanceUniqueName = "Site1.Pump01",
AlarmName = "HighPressure",
State = state,
Priority = 3,
Timestamp = timestamp
};
var bytes = original.ToByteArray();
var deserialized = AlarmStateUpdate.Parser.ParseFrom(bytes);
Assert.Equal(original.InstanceUniqueName, deserialized.InstanceUniqueName);
Assert.Equal(original.AlarmName, deserialized.AlarmName);
Assert.Equal(original.State, deserialized.State);
Assert.Equal(original.Priority, deserialized.Priority);
Assert.Equal(original.Timestamp, deserialized.Timestamp);
}
[Fact]
public void SiteStreamEvent_OneOf_AttributeChanged()
{
var evt = new SiteStreamEvent
{
CorrelationId = "corr-123",
AttributeChanged = new AttributeValueUpdate
{
InstanceUniqueName = "Site1.Pump01",
AttributePath = "Modules.PressureModule",
AttributeName = "CurrentPressure",
Value = "42.5",
Quality = Quality.Good,
Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow)
}
};
Assert.Equal(SiteStreamEvent.EventOneofCase.AttributeChanged, evt.EventCase);
Assert.NotNull(evt.AttributeChanged);
Assert.Null(evt.AlarmChanged);
// Round-trip
var bytes = evt.ToByteArray();
var deserialized = SiteStreamEvent.Parser.ParseFrom(bytes);
Assert.Equal(SiteStreamEvent.EventOneofCase.AttributeChanged, deserialized.EventCase);
Assert.Equal("corr-123", deserialized.CorrelationId);
Assert.Equal("42.5", deserialized.AttributeChanged.Value);
}
[Fact]
public void SiteStreamEvent_OneOf_AlarmChanged()
{
var evt = new SiteStreamEvent
{
CorrelationId = "corr-456",
AlarmChanged = new AlarmStateUpdate
{
InstanceUniqueName = "Site1.Pump01",
AlarmName = "HighPressure",
State = AlarmStateEnum.AlarmStateActive,
Priority = 1,
Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow)
}
};
Assert.Equal(SiteStreamEvent.EventOneofCase.AlarmChanged, evt.EventCase);
Assert.NotNull(evt.AlarmChanged);
Assert.Null(evt.AttributeChanged);
// Round-trip
var bytes = evt.ToByteArray();
var deserialized = SiteStreamEvent.Parser.ParseFrom(bytes);
Assert.Equal(SiteStreamEvent.EventOneofCase.AlarmChanged, deserialized.EventCase);
Assert.Equal("corr-456", deserialized.CorrelationId);
Assert.Equal(AlarmStateEnum.AlarmStateActive, deserialized.AlarmChanged.State);
Assert.Equal(1, deserialized.AlarmChanged.Priority);
}
}