Compare commits

...

4 Commits

Author SHA1 Message Date
Joseph Doherty
9b0a80dcbd feat: add GrpcNodeAAddress/GrpcNodeBAddress to Site entity, CLI, and UI 2026-03-21 11:45:22 -04:00
Joseph Doherty
64ee316609 feat: add GrpcPort config to NodeOptions with startup validation 2026-03-21 11:42:41 -04:00
Joseph Doherty
deb58e1f17 feat: add sitestream.proto definition and generated gRPC stubs
Proto3 definition with SiteStreamService (server streaming), Quality and
AlarmStateEnum enums with UNSPECIFIED=0, google.protobuf.Timestamp for
cross-platform timestamps. Pre-generated C# stubs checked in (no protoc
at build time). 10 roundtrip tests covering serialization, oneof
discrimination, and Timestamp<->DateTimeOffset conversion.
2026-03-21 11:41:01 -04:00
Joseph Doherty
826cfbee31 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.
2026-03-21 11:37:39 -04:00
14 changed files with 1852 additions and 12 deletions

View File

@@ -53,6 +53,8 @@ public static class SiteCommands
var descOption = new Option<string?>("--description") { Description = "Site description" };
var nodeAOption = new Option<string?>("--node-a-address") { Description = "Akka address for Node A" };
var nodeBOption = new Option<string?>("--node-b-address") { Description = "Akka address for Node B" };
var grpcNodeAOption = new Option<string?>("--grpc-node-a-address") { Description = "gRPC address for Node A" };
var grpcNodeBOption = new Option<string?>("--grpc-node-b-address") { Description = "gRPC address for Node B" };
var cmd = new Command("create") { Description = "Create a new site" };
cmd.Add(nameOption);
@@ -60,6 +62,8 @@ public static class SiteCommands
cmd.Add(descOption);
cmd.Add(nodeAOption);
cmd.Add(nodeBOption);
cmd.Add(grpcNodeAOption);
cmd.Add(grpcNodeBOption);
cmd.SetAction(async (ParseResult result) =>
{
var name = result.GetValue(nameOption)!;
@@ -67,9 +71,11 @@ public static class SiteCommands
var desc = result.GetValue(descOption);
var nodeA = result.GetValue(nodeAOption);
var nodeB = result.GetValue(nodeBOption);
var grpcNodeA = result.GetValue(grpcNodeAOption);
var grpcNodeB = result.GetValue(grpcNodeBOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new CreateSiteCommand(name, identifier, desc, nodeA, nodeB));
new CreateSiteCommand(name, identifier, desc, nodeA, nodeB, grpcNodeA, grpcNodeB));
});
return cmd;
}
@@ -81,6 +87,8 @@ public static class SiteCommands
var descOption = new Option<string?>("--description") { Description = "Site description" };
var nodeAOption = new Option<string?>("--node-a-address") { Description = "Akka address for Node A" };
var nodeBOption = new Option<string?>("--node-b-address") { Description = "Akka address for Node B" };
var grpcNodeAOption = new Option<string?>("--grpc-node-a-address") { Description = "gRPC address for Node A" };
var grpcNodeBOption = new Option<string?>("--grpc-node-b-address") { Description = "gRPC address for Node B" };
var cmd = new Command("update") { Description = "Update an existing site" };
cmd.Add(idOption);
@@ -88,6 +96,8 @@ public static class SiteCommands
cmd.Add(descOption);
cmd.Add(nodeAOption);
cmd.Add(nodeBOption);
cmd.Add(grpcNodeAOption);
cmd.Add(grpcNodeBOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
@@ -95,9 +105,11 @@ public static class SiteCommands
var desc = result.GetValue(descOption);
var nodeA = result.GetValue(nodeAOption);
var nodeB = result.GetValue(nodeBOption);
var grpcNodeA = result.GetValue(grpcNodeAOption);
var grpcNodeB = result.GetValue(grpcNodeBOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new UpdateSiteCommand(id, name, desc, nodeA, nodeB));
new UpdateSiteCommand(id, name, desc, nodeA, nodeB, grpcNodeA, grpcNodeB));
});
return cmd;
}

View File

@@ -75,6 +75,18 @@
placeholder="akka.tcp://scadalink@host:port/user/site-communication" />
</div>
</div>
<div class="row g-2 align-items-end mt-1">
<div class="col-md-6">
<label class="form-label small">gRPC Node A Address</label>
<input type="text" class="form-control form-control-sm" @bind="_formGrpcNodeAAddress"
placeholder="http://host:8083" />
</div>
<div class="col-md-6">
<label class="form-label small">gRPC Node B Address (optional)</label>
<input type="text" class="form-control form-control-sm" @bind="_formGrpcNodeBAddress"
placeholder="http://host:8083" />
</div>
</div>
@if (_formError != null)
{
<div class="text-danger small mt-1">@_formError</div>
@@ -92,6 +104,8 @@
<th>Description</th>
<th>Node A</th>
<th>Node B</th>
<th>gRPC Node A</th>
<th>gRPC Node B</th>
<th>Data Connections</th>
<th style="width: 260px;">Actions</th>
</tr>
@@ -100,7 +114,7 @@
@if (_sites.Count == 0)
{
<tr>
<td colspan="8" class="text-muted text-center">No sites configured.</td>
<td colspan="10" class="text-muted text-center">No sites configured.</td>
</tr>
}
@foreach (var site in _sites)
@@ -112,6 +126,8 @@
<td class="text-muted small">@(site.Description ?? "—")</td>
<td class="small text-truncate" style="max-width: 200px;" title="@site.NodeAAddress">@(site.NodeAAddress ?? "—")</td>
<td class="small text-truncate" style="max-width: 200px;" title="@site.NodeBAddress">@(site.NodeBAddress ?? "—")</td>
<td class="small text-truncate" style="max-width: 200px;" title="@site.GrpcNodeAAddress">@(site.GrpcNodeAAddress ?? "—")</td>
<td class="small text-truncate" style="max-width: 200px;" title="@site.GrpcNodeBAddress">@(site.GrpcNodeBAddress ?? "—")</td>
<td>
@{
var conns = _siteConnections.GetValueOrDefault(site.Id);
@@ -163,6 +179,8 @@
private string? _formDescription;
private string? _formNodeAAddress;
private string? _formNodeBAddress;
private string? _formGrpcNodeAAddress;
private string? _formGrpcNodeBAddress;
private string? _formError;
private bool _deploying;
@@ -207,6 +225,8 @@
_formDescription = null;
_formNodeAAddress = null;
_formNodeBAddress = null;
_formGrpcNodeAAddress = null;
_formGrpcNodeBAddress = null;
_formError = null;
_showForm = true;
}
@@ -219,6 +239,8 @@
_formDescription = site.Description;
_formNodeAAddress = site.NodeAAddress;
_formNodeBAddress = site.NodeBAddress;
_formGrpcNodeAAddress = site.GrpcNodeAAddress;
_formGrpcNodeBAddress = site.GrpcNodeBAddress;
_formError = null;
_showForm = true;
}
@@ -248,6 +270,8 @@
_editingSite.Description = _formDescription?.Trim();
_editingSite.NodeAAddress = _formNodeAAddress?.Trim();
_editingSite.NodeBAddress = _formNodeBAddress?.Trim();
_editingSite.GrpcNodeAAddress = _formGrpcNodeAAddress?.Trim();
_editingSite.GrpcNodeBAddress = _formGrpcNodeBAddress?.Trim();
await SiteRepository.UpdateSiteAsync(_editingSite);
}
else
@@ -261,7 +285,9 @@
{
Description = _formDescription?.Trim(),
NodeAAddress = _formNodeAAddress?.Trim(),
NodeBAddress = _formNodeBAddress?.Trim()
NodeBAddress = _formNodeBAddress?.Trim(),
GrpcNodeAAddress = _formGrpcNodeAAddress?.Trim(),
GrpcNodeBAddress = _formGrpcNodeBAddress?.Trim()
};
await SiteRepository.AddSiteAsync(site);
}

View File

@@ -8,6 +8,8 @@ public class Site
public string? Description { get; set; }
public string? NodeAAddress { get; set; }
public string? NodeBAddress { get; set; }
public string? GrpcNodeAAddress { get; set; }
public string? GrpcNodeBAddress { get; set; }
public Site(string name, string siteIdentifier)
{

View File

@@ -2,8 +2,8 @@ namespace ScadaLink.Commons.Messages.Management;
public record ListSitesCommand;
public record GetSiteCommand(int SiteId);
public record CreateSiteCommand(string Name, string SiteIdentifier, string? Description, string? NodeAAddress = null, string? NodeBAddress = null);
public record UpdateSiteCommand(int SiteId, string Name, string? Description, string? NodeAAddress = null, string? NodeBAddress = null);
public record CreateSiteCommand(string Name, string SiteIdentifier, string? Description, string? NodeAAddress = null, string? NodeBAddress = null, string? GrpcNodeAAddress = null, string? GrpcNodeBAddress = null);
public record UpdateSiteCommand(int SiteId, string Name, string? Description, string? NodeAAddress = null, string? NodeBAddress = null, string? GrpcNodeAAddress = null, string? GrpcNodeBAddress = null);
public record DeleteSiteCommand(int SiteId);
public record ListAreasCommand(int SiteId);
public record CreateAreaCommand(int SiteId, string Name, int? ParentAreaId);

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

@@ -6,4 +6,5 @@ public class NodeOptions
public string NodeHostname { get; set; } = string.Empty;
public string? SiteId { get; set; }
public int RemotingPort { get; set; } = 8081;
public int GrpcPort { get; set; } = 8083;
}

View File

@@ -42,6 +42,10 @@ public static class StartupValidator
if (role == "Site")
{
var grpcPortStr = nodeSection["GrpcPort"];
if (grpcPortStr != null && (!int.TryParse(grpcPortStr, out var gp) || gp < 1 || gp > 65535))
errors.Add("ScadaLink:Node:GrpcPort must be 1-65535");
var dbSection = configuration.GetSection("ScadaLink:Database");
if (string.IsNullOrEmpty(dbSection["SiteDbPath"]))
errors.Add("ScadaLink:Database:SiteDbPath required for Site nodes");

View File

@@ -4,7 +4,8 @@
"Role": "Site",
"NodeHostname": "localhost",
"SiteId": "site-a",
"RemotingPort": 8082
"RemotingPort": 8082,
"GrpcPort": 8083
},
"Cluster": {
"SeedNodes": [

View File

@@ -596,7 +596,9 @@ public class ManagementActor : ReceiveActor
{
Description = cmd.Description,
NodeAAddress = cmd.NodeAAddress,
NodeBAddress = cmd.NodeBAddress
NodeBAddress = cmd.NodeBAddress,
GrpcNodeAAddress = cmd.GrpcNodeAAddress,
GrpcNodeBAddress = cmd.GrpcNodeBAddress
};
await repo.AddSiteAsync(site);
await repo.SaveChangesAsync();
@@ -615,6 +617,8 @@ public class ManagementActor : ReceiveActor
site.Description = cmd.Description;
site.NodeAAddress = cmd.NodeAAddress;
site.NodeBAddress = cmd.NodeBAddress;
site.GrpcNodeAAddress = cmd.GrpcNodeAAddress;
site.GrpcNodeBAddress = cmd.GrpcNodeBAddress;
await repo.UpdateSiteAsync(site);
await repo.SaveChangesAsync();
var commService = sp.GetService<CommunicationService>();

View File

@@ -0,0 +1,157 @@
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);
}
[Fact]
public void Timestamp_DateTimeOffset_FullRoundTrip()
{
var original = new DateTimeOffset(2026, 3, 21, 14, 30, 45, 123, TimeSpan.Zero);
var update = new AttributeValueUpdate
{
InstanceUniqueName = "Motor-1",
AttributePath = "Speed",
AttributeName = "Speed",
Value = "42.5",
Quality = Quality.Good,
Timestamp = Timestamp.FromDateTimeOffset(original)
};
var bytes = update.ToByteArray();
var deserialized = AttributeValueUpdate.Parser.ParseFrom(bytes);
var roundTripped = deserialized.Timestamp.ToDateTimeOffset();
Assert.Equal(original.Year, roundTripped.Year);
Assert.Equal(original.Month, roundTripped.Month);
Assert.Equal(original.Day, roundTripped.Day);
Assert.Equal(original.Hour, roundTripped.Hour);
Assert.Equal(original.Minute, roundTripped.Minute);
Assert.Equal(original.Second, roundTripped.Second);
Assert.Equal(original.Millisecond, roundTripped.Millisecond);
Assert.Equal(TimeSpan.Zero, roundTripped.Offset);
}
}

View File

@@ -217,6 +217,43 @@ public class StartupValidatorTests
Assert.Contains("SeedNodes must have at least 2 entries", ex.Message);
}
[Theory]
[InlineData("0")]
[InlineData("-1")]
[InlineData("65536")]
[InlineData("abc")]
public void Site_InvalidGrpcPort_FailsValidation(string grpcPort)
{
var values = ValidSiteConfig();
values["ScadaLink:Node:GrpcPort"] = grpcPort;
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("GrpcPort must be 1-65535", ex.Message);
}
[Fact]
public void Site_ValidGrpcPort_PassesValidation()
{
var values = ValidSiteConfig();
values["ScadaLink:Node:GrpcPort"] = "8083";
var config = BuildConfig(values);
var ex = Record.Exception(() => StartupValidator.Validate(config));
Assert.Null(ex);
}
[Fact]
public void Central_InvalidGrpcPort_NotValidated()
{
var values = ValidCentralConfig();
values["ScadaLink:Node:GrpcPort"] = "0";
var config = BuildConfig(values);
var ex = Record.Exception(() => StartupValidator.Validate(config));
Assert.Null(ex);
}
[Fact]
public void MultipleErrors_AllReported()
{