From 3c8345ceb5743eeef8e64147e9b10f515db47fd7 Mon Sep 17 00:00:00 2001 From: Bastian Burger <22341213+bastbu@users.noreply.github.com> Date: Tue, 17 May 2022 17:56:04 +0200 Subject: [PATCH 01/22] Adapt CI for temporary branch (#1696) --- .github/workflows/ci.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f5332e8ffd..3a70e8c304 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -4,6 +4,7 @@ on: # rebuild any PRs and main branch changes branches: - master - dev + - ocw-edge/dev paths-ignore: - 'Docs/**' - 'Arduino/**' From cac5e8804dfc49b17e9fddeae48ec367f4d003af Mon Sep 17 00:00:00 2001 From: Bastian Burger <22341213+bastbu@users.noreply.github.com> Date: Tue, 17 May 2022 18:04:51 +0200 Subject: [PATCH 02/22] feat: split module connection host (#1695) --- .../LoRaWan.NetworkServer/AssemblyInfo.cs | 1 + .../BasicsStationNetworkServerStartup.cs | 1 + .../BasicsStation/LnsOperation.cs | 123 ++++++++ .../ModuleConnection/ModuleConnectionHost.cs | 110 +------ Tests/Simulation/SimulatedLoadTests.cs | 8 +- Tests/Unit/NetworkServer/LnsOperationTests.cs | 147 ++++++++++ .../NetworkServer/ModuleConnectionHostTest.cs | 272 ++++-------------- 7 files changed, 339 insertions(+), 323 deletions(-) create mode 100644 LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/LnsOperation.cs create mode 100644 Tests/Unit/NetworkServer/LnsOperationTests.cs diff --git a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/AssemblyInfo.cs b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/AssemblyInfo.cs index 2c948b9a19..2b2840ebe5 100644 --- a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/AssemblyInfo.cs +++ b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/AssemblyInfo.cs @@ -5,3 +5,4 @@ [assembly: InternalsVisibleTo("LoRaWan.Tests.Common")] [assembly: InternalsVisibleTo("LoRaWan.Tests.Unit")] [assembly: InternalsVisibleTo("LoRaWan.Tests.Integration")] +[assembly: InternalsVisibleTo("LoRaWan.Tests.Simulation")] diff --git a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/BasicsStationNetworkServerStartup.cs b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/BasicsStationNetworkServerStartup.cs index e02470da97..b8755b92b2 100644 --- a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/BasicsStationNetworkServerStartup.cs +++ b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/BasicsStationNetworkServerStartup.cs @@ -80,6 +80,7 @@ public void ConfigureServices(IServiceCollection services) .AddApiClient(NetworkServerConfiguration, ApiVersion.LatestVersion) .AddSingleton(NetworkServerConfiguration) .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/LnsOperation.cs b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/LnsOperation.cs new file mode 100644 index 0000000000..78ba970a09 --- /dev/null +++ b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/LnsOperation.cs @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace LoRaWan.NetworkServer.BasicsStation +{ + using System.Diagnostics.Metrics; + using System.Net; + using System.Text.Json; + using System.Threading; + using System.Threading.Tasks; + using LoRaTools; + using Microsoft.Extensions.Logging; + + internal interface ILnsRemoteCall + { + Task ClearCacheAsync(); + Task CloseConnectionAsync(string json, CancellationToken cancellationToken); + Task SendCloudToDeviceMessageAsync(string json, CancellationToken cancellationToken); + } + + internal sealed class LnsRemoteCall : ILnsRemoteCall + { + internal const string ClosedConnectionLog = "Device connection was closed "; + private static readonly JsonSerializerOptions JsonSerializerOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + + private readonly NetworkServerConfiguration networkServerConfiguration; + private readonly IClassCDeviceMessageSender classCDeviceMessageSender; + private readonly ILoRaDeviceRegistry loRaDeviceRegistry; + private readonly ILogger logger; + private readonly Counter forceClosedConnections; + + public LnsRemoteCall(NetworkServerConfiguration networkServerConfiguration, + IClassCDeviceMessageSender classCDeviceMessageSender, + ILoRaDeviceRegistry loRaDeviceRegistry, + ILogger logger, + Meter meter) + { + this.networkServerConfiguration = networkServerConfiguration; + this.classCDeviceMessageSender = classCDeviceMessageSender; + this.loRaDeviceRegistry = loRaDeviceRegistry; + this.logger = logger; + this.forceClosedConnections = meter.CreateCounter(MetricRegistry.ForceClosedClientConnections); + } + + public async Task SendCloudToDeviceMessageAsync(string json, CancellationToken cancellationToken) + { + if (!string.IsNullOrEmpty(json)) + { + ReceivedLoRaCloudToDeviceMessage c2d; + + try + { + c2d = JsonSerializer.Deserialize(json, JsonSerializerOptions); + } + catch (JsonException ex) + { + this.logger.LogError(ex, $"Impossible to parse Json for c2d message, error: '{ex}'"); + return HttpStatusCode.BadRequest; + } + + using var scope = this.logger.BeginDeviceScope(c2d.DevEUI); + this.logger.LogDebug($"received cloud to device message from direct method: {json}"); + + if (await this.classCDeviceMessageSender.SendAsync(c2d, cancellationToken)) + { + return HttpStatusCode.OK; + } + } + + return HttpStatusCode.BadRequest; + } + + public async Task CloseConnectionAsync(string json, CancellationToken cancellationToken) + { + ReceivedLoRaCloudToDeviceMessage c2d; + + try + { + c2d = JsonSerializer.Deserialize(json, JsonSerializerOptions); + } + catch (JsonException ex) + { + this.logger.LogError(ex, "Unable to parse Json when attempting to close the connection."); + return HttpStatusCode.BadRequest; + } + + if (c2d == null) + { + this.logger.LogError("Missing payload when attempting to close the connection."); + return HttpStatusCode.BadRequest; + } + + if (c2d.DevEUI == null) + { + this.logger.LogError("DevEUI missing, cannot identify device to close connection for; message Id '{MessageId}'", c2d.MessageId); + return HttpStatusCode.BadRequest; + } + + using var scope = this.logger.BeginDeviceScope(c2d.DevEUI); + + var loRaDevice = await this.loRaDeviceRegistry.GetDeviceByDevEUIAsync(c2d.DevEUI.Value); + if (loRaDevice == null) + { + this.logger.LogError("Could not retrieve LoRa device; message id '{MessageId}'", c2d.MessageId); + return HttpStatusCode.NotFound; + } + + loRaDevice.IsConnectionOwner = false; + await loRaDevice.CloseConnectionAsync(cancellationToken, force: true); + + this.logger.LogInformation(ClosedConnectionLog + "from gateway with id '{GatewayId}', message id '{MessageId}'", this.networkServerConfiguration.GatewayID, c2d.MessageId); + this.forceClosedConnections.Add(1); + + return HttpStatusCode.OK; + } + + public async Task ClearCacheAsync() + { + await this.loRaDeviceRegistry.ResetDeviceCacheAsync(); + return HttpStatusCode.OK; + } + } +} diff --git a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/ModuleConnection/ModuleConnectionHost.cs b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/ModuleConnection/ModuleConnectionHost.cs index 94190cec4d..ecc18cd2a9 100644 --- a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/ModuleConnection/ModuleConnectionHost.cs +++ b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/ModuleConnection/ModuleConnectionHost.cs @@ -3,7 +3,6 @@ namespace LoRaWan.NetworkServer.BasicsStation.ModuleConnection { - using LoRaTools; using LoRaTools.Utils; using Microsoft.Azure.Devices.Client; using Microsoft.Azure.Devices.Client.Exceptions; @@ -13,42 +12,34 @@ namespace LoRaWan.NetworkServer.BasicsStation.ModuleConnection using System.Configuration; using System.Diagnostics.Metrics; using System.Net; - using System.Text.Json; using System.Threading; using System.Threading.Tasks; - public sealed class ModuleConnectionHost : IAsyncDisposable + internal sealed class ModuleConnectionHost : IAsyncDisposable { private const string LnsVersionPropertyName = "LnsVersion"; private readonly NetworkServerConfiguration networkServerConfiguration; - private readonly IClassCDeviceMessageSender classCMessageSender; - private readonly ILoRaDeviceRegistry loRaDeviceRegistry; private readonly LoRaDeviceAPIServiceBase loRaDeviceAPIService; + private readonly ILnsRemoteCall lnsRemoteCall; private readonly ILogger logger; private readonly Counter unhandledExceptionCount; - private readonly Counter forceClosedConnections; private ILoraModuleClient loRaModuleClient; private readonly ILoRaModuleClientFactory loRaModuleClientFactory; - public const string ClosedConnectionLog = "Device connection was closed "; - public ModuleConnectionHost( NetworkServerConfiguration networkServerConfiguration, - IClassCDeviceMessageSender defaultClassCDevicesMessageSender, ILoRaModuleClientFactory loRaModuleClientFactory, - ILoRaDeviceRegistry loRaDeviceRegistry, LoRaDeviceAPIServiceBase loRaDeviceAPIService, + ILnsRemoteCall lnsRemoteCall, ILogger logger, Meter meter) { this.networkServerConfiguration = networkServerConfiguration ?? throw new ArgumentNullException(nameof(networkServerConfiguration)); - this.classCMessageSender = defaultClassCDevicesMessageSender ?? throw new ArgumentNullException(nameof(defaultClassCDevicesMessageSender)); - this.loRaDeviceRegistry = loRaDeviceRegistry ?? throw new ArgumentNullException(nameof(loRaDeviceRegistry)); this.loRaDeviceAPIService = loRaDeviceAPIService ?? throw new ArgumentNullException(nameof(loRaDeviceAPIService)); + this.lnsRemoteCall = lnsRemoteCall ?? throw new ArgumentNullException(nameof(lnsRemoteCall)); this.loRaModuleClientFactory = loRaModuleClientFactory ?? throw new ArgumentNullException(nameof(loRaModuleClientFactory)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.unhandledExceptionCount = (meter ?? throw new ArgumentNullException(nameof(meter))).CreateCounter(MetricRegistry.UnhandledExceptions); - this.forceClosedConnections = meter.CreateCounter(MetricRegistry.ForceClosedClientConnections); } public async Task CreateAsync(CancellationToken cancellationToken) @@ -97,109 +88,34 @@ internal async Task OnDirectMethodCalled(MethodRequest methodReq try { + using var cts = methodRequest.ResponseTimeout is { } someResponseTimeout ? new CancellationTokenSource(someResponseTimeout) : null; + var token = cts?.Token ?? CancellationToken.None; + if (string.Equals(Constants.CloudToDeviceClearCache, methodRequest.Name, StringComparison.OrdinalIgnoreCase)) { - return await ClearCacheAsync(); + return AsMethodResponse(await this.lnsRemoteCall.ClearCacheAsync()); } else if (string.Equals(Constants.CloudToDeviceCloseConnection, methodRequest.Name, StringComparison.OrdinalIgnoreCase)) { - return await CloseConnectionAsync(methodRequest); + return AsMethodResponse(await this.lnsRemoteCall.CloseConnectionAsync(methodRequest.DataAsJson, token)); } else if (string.Equals(Constants.CloudToDeviceDecoderElementName, methodRequest.Name, StringComparison.OrdinalIgnoreCase)) { - return await SendCloudToDeviceMessageAsync(methodRequest); + return AsMethodResponse(await this.lnsRemoteCall.SendCloudToDeviceMessageAsync(methodRequest.DataAsJson, token)); } this.logger.LogError($"Unknown direct method called: {methodRequest.Name}"); - return new MethodResponse((int)HttpStatusCode.BadRequest); + return AsMethodResponse(HttpStatusCode.BadRequest); } catch (Exception ex) when (ExceptionFilterUtility.False(() => this.logger.LogError(ex, $"An exception occurred on a direct method call: {ex}"), () => this.unhandledExceptionCount.Add(1))) { throw; } - } - - private async Task SendCloudToDeviceMessageAsync(MethodRequest methodRequest) - { - if (!string.IsNullOrEmpty(methodRequest.DataAsJson)) - { - ReceivedLoRaCloudToDeviceMessage c2d = null; - - try - { - c2d = JsonSerializer.Deserialize(methodRequest.DataAsJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - } - catch (JsonException ex) - { - this.logger.LogError($"Impossible to parse Json for c2d message for device {c2d?.DevEUI}, error: {ex}"); - return new MethodResponse((int)HttpStatusCode.BadRequest); - } - - using var scope = this.logger.BeginDeviceScope(c2d.DevEUI); - this.logger.LogDebug($"received cloud to device message from direct method: {methodRequest.DataAsJson}"); - - using var cts = methodRequest.ResponseTimeout.HasValue ? new CancellationTokenSource(methodRequest.ResponseTimeout.Value) : null; - - if (await this.classCMessageSender.SendAsync(c2d, cts?.Token ?? CancellationToken.None)) - { - return new MethodResponse((int)HttpStatusCode.OK); - } - } - - return new MethodResponse((int)HttpStatusCode.BadRequest); - } - - private async Task ClearCacheAsync() - { - await this.loRaDeviceRegistry.ResetDeviceCacheAsync(); - return new MethodResponse((int)HttpStatusCode.OK); - } - - private async Task CloseConnectionAsync(MethodRequest methodRequest) - { - ReceivedLoRaCloudToDeviceMessage c2d = null; - - try - { - c2d = methodRequest.DataAsJson is { } json ? JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) : null; - } - catch (JsonException ex) - { - this.logger.LogError(ex, "Unable to parse Json for direct method '{MethodName}' for device '{DevEui}', message id '{MessageId}'", methodRequest.Name, c2d?.DevEUI, c2d?.MessageId); - return new MethodResponse((int)HttpStatusCode.BadRequest); - } - - if (c2d == null) - { - this.logger.LogError("Missing payload for direct method '{MethodName}'", methodRequest.Name); - return new MethodResponse((int)HttpStatusCode.BadRequest); - } - - if (c2d.DevEUI == null) - { - this.logger.LogError("DevEUI missing, cannot identify device to close connection for; message Id '{MessageId}'", c2d.MessageId); - return new MethodResponse((int)HttpStatusCode.BadRequest); - } - - using var scope = this.logger.BeginDeviceScope(c2d.DevEUI); - - var loRaDevice = await this.loRaDeviceRegistry.GetDeviceByDevEUIAsync(c2d.DevEUI.Value); - if (loRaDevice == null) - { - this.logger.LogError("Could not retrieve LoRa device; message id '{MessageId}'", c2d.MessageId); - return new MethodResponse((int)HttpStatusCode.NotFound); - } - - loRaDevice.IsConnectionOwner = false; - using var cts = methodRequest.ResponseTimeout is { } timeout ? new CancellationTokenSource(timeout) : null; - await loRaDevice.CloseConnectionAsync(cts?.Token ?? CancellationToken.None, force: true); - - this.logger.LogInformation(ClosedConnectionLog + "from gateway with id '{GatewayId}', message id '{MessageId}'", this.networkServerConfiguration.GatewayID, c2d.MessageId); - this.forceClosedConnections.Add(1); - return new MethodResponse((int)HttpStatusCode.OK); + static MethodResponse AsMethodResponse(HttpStatusCode httpStatusCode) => + new MethodResponse((int)httpStatusCode); } /// diff --git a/Tests/Simulation/SimulatedLoadTests.cs b/Tests/Simulation/SimulatedLoadTests.cs index 91b62eb7e5..eb61160a7d 100644 --- a/Tests/Simulation/SimulatedLoadTests.cs +++ b/Tests/Simulation/SimulatedLoadTests.cs @@ -22,7 +22,7 @@ namespace LoRaWan.Tests.Simulation using static MoreLinq.Extensions.RepeatExtension; using static MoreLinq.Extensions.IndexExtension; using static MoreLinq.Extensions.TransposeExtension; - using LoRaWan.NetworkServer.BasicsStation.ModuleConnection; + using LoRaWan.NetworkServer.BasicsStation; [Trait("Category", "SkipWhenLiveUnitTesting")] public sealed class SimulatedLoadTests : IntegrationTestBaseSim, IAsyncLifetime @@ -101,7 +101,7 @@ public async Task Ensures_Disconnect_Happens_For_Losing_Gateway_When_Connection_ await Task.Delay(messagesToSendEachLNS * IntervalBetweenMessages); _ = await TestFixture.AssertNetworkServerModuleLogExistsAsync( - x => !x.Contains(ModuleConnectionHost.ClosedConnectionLog, StringComparison.Ordinal), + x => !x.Contains(LnsRemoteCall.ClosedConnectionLog, StringComparison.Ordinal), new SearchLogOptions("No connection switch should be logged") { TreatAsError = true }); // act: change basics station that the device is listened from and therefore the gateway it uses as well @@ -111,8 +111,8 @@ public async Task Ensures_Disconnect_Happens_For_Losing_Gateway_When_Connection_ // assert var expectedLnsToDropConnection = Configuration.LnsEndpointsForSimulator.First().Key; _ = await TestFixture.AssertNetworkServerModuleLogExistsAsync( - x => x.Contains(ModuleConnectionHost.ClosedConnectionLog, StringComparison.Ordinal) && x.Contains(expectedLnsToDropConnection, StringComparison.Ordinal), - new SearchLogOptions($"{ModuleConnectionHost.ClosedConnectionLog} and {expectedLnsToDropConnection}") { TreatAsError = true }); + x => x.Contains(LnsRemoteCall.ClosedConnectionLog, StringComparison.Ordinal) && x.Contains(expectedLnsToDropConnection, StringComparison.Ordinal), + new SearchLogOptions($"{LnsRemoteCall.ClosedConnectionLog} and {expectedLnsToDropConnection}") { TreatAsError = true }); await AssertIotHubMessageCountAsync(simulatedDevice, messagesToSendEachLNS * 2); } diff --git a/Tests/Unit/NetworkServer/LnsOperationTests.cs b/Tests/Unit/NetworkServer/LnsOperationTests.cs new file mode 100644 index 0000000000..3cc5ef64df --- /dev/null +++ b/Tests/Unit/NetworkServer/LnsOperationTests.cs @@ -0,0 +1,147 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace LoRaWan.Tests.Unit.NetworkServer +{ + using System; + using System.Net; + using System.Text.Json; + using System.Threading; + using System.Threading.Tasks; + using Bogus; + using LoRaWan.NetworkServer; + using LoRaWan.NetworkServer.BasicsStation; + using LoRaWan.Tests.Common; + using Microsoft.Extensions.Logging; + using Moq; + using Xunit; + + public sealed class LnsRemoteCallTests + { + private readonly Faker faker = new(); + private readonly NetworkServerConfiguration networkServerConfiguration; + private readonly Mock classCMessageSender; + private readonly Mock loRaDeviceRegistry; + private readonly Mock> logger; + private readonly LnsRemoteCall subject; + + public LnsRemoteCallTests() + { + this.networkServerConfiguration = new NetworkServerConfiguration(); + this.classCMessageSender = new Mock(); + this.loRaDeviceRegistry = new Mock(); + this.logger = new Mock>(); + this.subject = new LnsRemoteCall(this.networkServerConfiguration, + this.classCMessageSender.Object, + this.loRaDeviceRegistry.Object, + this.logger.Object, + TestMeter.Instance); + } + + + [Fact] + public async Task OnDirectMethodCall_DropConnection_Should_Work_As_Expected() + { + // arrange + var devEui = new DevEui(0); + var mockedDevice = new Mock(null, devEui, null); + _ = this.loRaDeviceRegistry.Setup(x => x.GetDeviceByDevEUIAsync(devEui)).ReturnsAsync(mockedDevice.Object); + var c2d = JsonSerializer.Serialize(new + { + DevEui = devEui.ToString(), + Fport = 1, + MessageId = Guid.NewGuid(), + }); + + // act + _ = await this.subject.CloseConnectionAsync(c2d, CancellationToken.None); + + // assert + this.loRaDeviceRegistry.VerifyAll(); + mockedDevice.Verify(x => x.CloseConnectionAsync(It.IsAny(), true), Times.Once); + } + + [Fact] + public async Task ClearCache_When_Correct_Should_Work() + { + // arrange + this.loRaDeviceRegistry.Setup(x => x.ResetDeviceCacheAsync()).Returns(Task.CompletedTask); + this.networkServerConfiguration.IoTEdgeTimeout = 5; + + // act + await this.subject.ClearCacheAsync(); + + // assert + this.loRaDeviceRegistry.VerifyAll(); + } + + public static TheoryData DropConnectionInvalidMessages => + TheoryDataFactory.From( + (string.Empty, "Unable to parse Json when attempting to close"), + ("null", "Missing payload when attempting to close the"), + (JsonSerializer.Serialize(new { DevEui = (string)null, Fport = 1 }), "DevEUI missing"), + (JsonSerializer.Serialize(new { DevEui = new DevEui(0).ToString(), Fport = 1, MessageId = 123 }), "Unable to parse Json")); + + [Theory] + [MemberData(nameof(DropConnectionInvalidMessages))] + public async Task CloseConnectionAsync_Should_Return_Bad_Request_When_Invalid_Message(string json, string expectedLogPattern) + { + // act + var response = await this.subject.CloseConnectionAsync(json, CancellationToken.None); + + // assert + Assert.Equal(HttpStatusCode.BadRequest, response); + var log = Assert.Single(this.logger.GetLogInvocations()); + Assert.Matches(expectedLogPattern, log.Message); + this.loRaDeviceRegistry.VerifyNoOtherCalls(); + } + + [Fact] + public async Task CloseConnectionAsync_Should_Return_NotFound_When_Device_Not_Found() + { + // arrange + var devEui = new DevEui(0); + var c2d = JsonSerializer.Serialize(new { DevEui = devEui.ToString(), Fport = 1 }); + + // act + var response = await this.subject.CloseConnectionAsync(c2d, CancellationToken.None); + + // assert + Assert.Equal(HttpStatusCode.NotFound, response); + this.loRaDeviceRegistry.Verify(x => x.GetDeviceByDevEUIAsync(devEui), Times.Once); + this.loRaDeviceRegistry.VerifyNoOtherCalls(); + } + + [Fact] + public async Task SendCloudToDeviceMessageAsync_When_Correct_Should_Work() + { + // arrange + this.classCMessageSender.Setup(x => x.SendAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + var c2d = "{\"test\":\"asd\"}"; + + // act + var response = await this.subject.SendCloudToDeviceMessageAsync(c2d, CancellationToken.None); + + // assert + Assert.Equal(HttpStatusCode.OK, response); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task SendCloudToDeviceMessageAsync_When_ClassC_Msg_Is_Null_Or_Empty_Should_Return_Not_Found(string json) + { + this.classCMessageSender.Setup(x => x.SendAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + + var response = await this.subject.SendCloudToDeviceMessageAsync(json, CancellationToken.None); + Assert.Equal(HttpStatusCode.BadRequest, response); + } + + [Fact] + public async Task SendCloudToDeviceMessageAsync_When_ClassC_Msg_Is_Not_CorrectJson_Should_Return_Not_Found() + { + var response = await this.subject.SendCloudToDeviceMessageAsync(this.faker.Random.String2(10), CancellationToken.None); + Assert.Equal(HttpStatusCode.BadRequest, response); + } + } +} diff --git a/Tests/Unit/NetworkServer/ModuleConnectionHostTest.cs b/Tests/Unit/NetworkServer/ModuleConnectionHostTest.cs index ca09eb22f5..80574fe99f 100644 --- a/Tests/Unit/NetworkServer/ModuleConnectionHostTest.cs +++ b/Tests/Unit/NetworkServer/ModuleConnectionHostTest.cs @@ -5,17 +5,16 @@ namespace LoRaWan.Tests.Unit.NetworkServer { using Bogus; using LoRaWan.NetworkServer; + using LoRaWan.NetworkServer.BasicsStation; using LoRaWan.NetworkServer.BasicsStation.ModuleConnection; using LoRaWan.Tests.Common; using Microsoft.Azure.Devices.Client; using Microsoft.Azure.Devices.Client.Exceptions; using Microsoft.Azure.Devices.Shared; - using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Moq; using System; using System.Configuration; - using System.Net; using System.Net.Http; using System.Text; using System.Text.Json; @@ -23,51 +22,48 @@ namespace LoRaWan.Tests.Unit.NetworkServer using System.Threading.Tasks; using Xunit; - public class ModuleConnectionHostTest + public sealed class ModuleConnectionHostTest : IAsyncDisposable { - + private readonly NetworkServerConfiguration networkServerConfiguration; private readonly Mock loRaModuleClientFactory = new(); private readonly Mock loRaModuleClient = new(); private readonly LoRaDeviceAPIServiceBase loRaDeviceApiServiceBase = Mock.Of(); private readonly Faker faker = new Faker(); + private readonly Mock lnsRemoteCall; + private readonly ModuleConnectionHost subject; public ModuleConnectionHostTest() { + this.networkServerConfiguration = new NetworkServerConfiguration(); this.loRaModuleClient.Setup(x => x.DisposeAsync()); this.loRaModuleClientFactory.Setup(x => x.CreateAsync()).ReturnsAsync(loRaModuleClient.Object); + this.lnsRemoteCall = new Mock(); + this.subject = new ModuleConnectionHost(this.networkServerConfiguration, + this.loRaModuleClientFactory.Object, + this.loRaDeviceApiServiceBase, + this.lnsRemoteCall.Object, + NullLogger.Instance, + TestMeter.Instance); } [Fact] public void When_Constructor_Receives_Null_Parameters_Should_Throw() { - var networkServerConfiguration = new NetworkServerConfiguration(); - var classCMessageSender = Mock.Of(); - var loRaDeviceRegistry = Mock.Of(); - var loRaModuleClientFactory = Mock.Of(); - // ASSERT ArgumentNullException ex; - ex = Assert.Throws(() => new ModuleConnectionHost(null, classCMessageSender, loRaModuleClientFactory, loRaDeviceRegistry, loRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance)); + ex = Assert.Throws(() => new ModuleConnectionHost(null, this.loRaModuleClientFactory.Object, this.loRaDeviceApiServiceBase, this.lnsRemoteCall.Object, NullLogger.Instance, TestMeter.Instance)); Assert.Equal("networkServerConfiguration", ex.ParamName); - ex = Assert.Throws(() => new ModuleConnectionHost(networkServerConfiguration, null, loRaModuleClientFactory, loRaDeviceRegistry, loRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance)); - Assert.Equal("defaultClassCDevicesMessageSender", ex.ParamName); - ex = Assert.Throws(() => new ModuleConnectionHost(networkServerConfiguration, classCMessageSender, null, loRaDeviceRegistry, loRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance)); + ex = Assert.Throws(() => new ModuleConnectionHost(networkServerConfiguration, null, this.loRaDeviceApiServiceBase, this.lnsRemoteCall.Object, NullLogger.Instance, TestMeter.Instance)); Assert.Equal("loRaModuleClientFactory", ex.ParamName); - ex = Assert.Throws(() => new ModuleConnectionHost(networkServerConfiguration, classCMessageSender, loRaModuleClientFactory, null, loRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance)); - Assert.Equal("loRaDeviceRegistry", ex.ParamName); - ex = Assert.Throws(() => new ModuleConnectionHost(networkServerConfiguration, classCMessageSender, loRaModuleClientFactory, loRaDeviceRegistry, null, NullLogger.Instance, TestMeter.Instance)); + ex = Assert.Throws(() => new ModuleConnectionHost(networkServerConfiguration, this.loRaModuleClientFactory.Object, null, this.lnsRemoteCall.Object, NullLogger.Instance, TestMeter.Instance)); Assert.Equal("loRaDeviceAPIService", ex.ParamName); + ex = Assert.Throws(() => new ModuleConnectionHost(networkServerConfiguration, this.loRaModuleClientFactory.Object, this.loRaDeviceApiServiceBase, null, NullLogger.Instance, TestMeter.Instance)); + Assert.Equal("lnsRemoteCall", ex.ParamName); } [Fact] public async Task On_Desired_Properties_Correct_Update_Should_Update_Api_Service_Configuration() { - var networkServerConfiguration = Mock.Of(); - var classCMessageSender = Mock.Of(); - var loRaDeviceRegistry = Mock.Of(); - var loRaModuleClientFactory = Mock.Of(); - - await using var moduleClient = new ModuleConnectionHost(networkServerConfiguration, classCMessageSender, loRaModuleClientFactory, loRaDeviceRegistry, loRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance); var url1 = this.faker.Internet.Url(); var authCode = this.faker.Internet.Password(); @@ -77,7 +73,7 @@ public async Task On_Desired_Properties_Correct_Update_Should_Update_Api_Service FacadeAuthCode = authCode, }); - await moduleClient.OnDesiredPropertiesUpdate(new TwinCollection(input), null); + await this.subject.OnDesiredPropertiesUpdate(new TwinCollection(input), null); Assert.Equal(url1 + "/", loRaDeviceApiServiceBase.URL.ToString()); var url2 = this.faker.Internet.Url(); var input2 = JsonSerializer.Serialize(new @@ -85,7 +81,7 @@ public async Task On_Desired_Properties_Correct_Update_Should_Update_Api_Service FacadeServerUrl = url2, FacadeAuthCode = authCode, }); - await moduleClient.OnDesiredPropertiesUpdate(new TwinCollection(input2), null); + await this.subject.OnDesiredPropertiesUpdate(new TwinCollection(input2), null); Assert.Equal(url2 + "/", loRaDeviceApiServiceBase.URL.ToString()); Assert.Equal(authCode, loRaDeviceApiServiceBase.AuthCode.ToString()); } @@ -105,11 +101,7 @@ public async Task On_Desired_Properties_Incorrect_Update_Should_Not_Update_Api_S }; var localLoRaDeviceApiServiceBase = new LoRaDeviceAPIService(networkServerConfiguration, Mock.Of(), NullLogger.Instance, TestMeter.Instance); - var classCMessageSender = Mock.Of(); - var loRaDeviceRegistry = Mock.Of(); - var loRaModuleClientFactory = Mock.Of(); - - await using var moduleClientFactory = new ModuleConnectionHost(networkServerConfiguration, classCMessageSender, loRaModuleClientFactory, loRaDeviceRegistry, localLoRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance); + await using var moduleClientFactory = new ModuleConnectionHost(networkServerConfiguration, this.loRaModuleClientFactory.Object, localLoRaDeviceApiServiceBase, this.lnsRemoteCall.Object, NullLogger.Instance, TestMeter.Instance); await moduleClientFactory.OnDesiredPropertiesUpdate(new TwinCollection(twinUpdate), null); Assert.Equal(facadeUri + "/", localLoRaDeviceApiServiceBase.URL.ToString()); @@ -123,13 +115,6 @@ public async Task On_Desired_Properties_Incorrect_Update_Should_Not_Update_Api_S [InlineData(1000)] public async Task On_Desired_Properties_Correct_Update_Should_Update_Processing_Delay(int processingDelay) { - var networkServerConfiguration = new NetworkServerConfiguration(); - var classCMessageSender = Mock.Of(); - var loRaDeviceRegistry = Mock.Of(); - var loRaModuleClientFactory = Mock.Of(); - - await using var moduleClient = new ModuleConnectionHost(networkServerConfiguration, classCMessageSender, loRaModuleClientFactory, loRaDeviceRegistry, loRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance); - Assert.Equal(Constants.DefaultProcessingDelayInMilliseconds, networkServerConfiguration.ProcessingDelayInMilliseconds); var input = JsonSerializer.Serialize(new @@ -137,7 +122,7 @@ public async Task On_Desired_Properties_Correct_Update_Should_Update_Processing_ ProcessingDelayInMilliseconds = processingDelay, }); - await moduleClient.OnDesiredPropertiesUpdate(new TwinCollection(input), null); + await this.subject.OnDesiredPropertiesUpdate(new TwinCollection(input), null); Assert.Equal(processingDelay, networkServerConfiguration.ProcessingDelayInMilliseconds); } @@ -147,26 +132,19 @@ public async Task On_Desired_Properties_Correct_Update_Should_Update_Processing_ [InlineData("{ ProcessingDelay: 200 }")] public async Task On_Desired_Properties_Incorrect_Update_Should_Not_Update_Processing_Delay(string twinUpdate) { - var networkServerConfiguration = new NetworkServerConfiguration(); - var classCMessageSender = Mock.Of(); - var loRaDeviceRegistry = Mock.Of(); - var loRaModuleClientFactory = Mock.Of(); - - await using var moduleClient = new ModuleConnectionHost(networkServerConfiguration, classCMessageSender, loRaModuleClientFactory, loRaDeviceRegistry, loRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance); - - await moduleClient.OnDesiredPropertiesUpdate(new TwinCollection(twinUpdate), null); + await this.subject.OnDesiredPropertiesUpdate(new TwinCollection(twinUpdate), null); Assert.Equal(Constants.DefaultProcessingDelayInMilliseconds, networkServerConfiguration.ProcessingDelayInMilliseconds); } [Fact] public async Task InitModuleAsync_Update_Should_Perform_Happy_Path() { - var networkServerConfiguration = new NetworkServerConfiguration(); - var classCMessageSender = new Mock(MockBehavior.Strict); - var loRaDeviceRegistry = new Mock(MockBehavior.Strict); + var networkServerConfiguration = new NetworkServerConfiguration() + { + // Change the iot edge timeout. + IoTEdgeTimeout = 5 + }; - // Change the iot edge timeout. - networkServerConfiguration.IoTEdgeTimeout = 5; var facadeUri = this.faker.Internet.Url(); var facadeCode = this.faker.Internet.Password(); var processingDelay = 1000; @@ -183,7 +161,7 @@ public async Task InitModuleAsync_Update_Should_Perform_Happy_Path() loRaModuleClient.Setup(x => x.GetTwinAsync(It.IsAny())).ReturnsAsync(new Twin(twinProperty)); - await using var moduleClient = new ModuleConnectionHost(networkServerConfiguration, classCMessageSender.Object, this.loRaModuleClientFactory.Object, loRaDeviceRegistry.Object, loRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance); + await using var moduleClient = new ModuleConnectionHost(networkServerConfiguration, this.loRaModuleClientFactory.Object, loRaDeviceApiServiceBase, this.lnsRemoteCall.Object, NullLogger.Instance, TestMeter.Instance); await moduleClient.CreateAsync(CancellationToken.None); Assert.Equal(facadeUri + "/", loRaDeviceApiServiceBase.URL.ToString()); Assert.Equal(facadeCode, loRaDeviceApiServiceBase.AuthCode); @@ -195,9 +173,6 @@ public async Task InitModuleAsync_Update_Should_Perform_Happy_Path() [InlineData("{ FacadeAuthCode: 'asdasdada' }")] public async Task InitModuleAsync_Fails_When_Required_Twins_Are_Not_Set(string twin) { - var networkServerConfiguration = new NetworkServerConfiguration(); - var classCMessageSender = new Mock(MockBehavior.Strict); - var loRaDeviceRegistry = new Mock(MockBehavior.Strict); var loRaModuleClient = new Mock(); loRaModuleClient.Setup(x => x.DisposeAsync()); var loRaModuleClientFactory = new Mock(); @@ -210,7 +185,7 @@ public async Task InitModuleAsync_Fails_When_Required_Twins_Are_Not_Set(string t }; loRaModuleClient.Setup(x => x.GetTwinAsync(It.IsAny())).ReturnsAsync(new Twin(twinProperty)); - await using var moduleClient = new ModuleConnectionHost(networkServerConfiguration, classCMessageSender.Object, this.loRaModuleClientFactory.Object, loRaDeviceRegistry.Object, loRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance); + await using var moduleClient = new ModuleConnectionHost(this.networkServerConfiguration, this.loRaModuleClientFactory.Object, loRaDeviceApiServiceBase, this.lnsRemoteCall.Object, NullLogger.Instance, TestMeter.Instance); await Assert.ThrowsAsync(() => moduleClient.CreateAsync(CancellationToken.None)); } @@ -221,11 +196,6 @@ public async Task InitModuleAsync_Fails_When_Required_Twins_Are_Not_Set(string t [InlineData("invalidDelay")] public async Task InitModuleAsync_Does_Not_Fail_When_Processing_Delay_Missing_Or_Incorrect(string processingDelay) { - var networkServerConfiguration = new NetworkServerConfiguration(); - var classCMessageSender = new Mock(MockBehavior.Strict); - var loRaDeviceRegistry = new Mock(MockBehavior.Strict); - - //networkServerConfiguration.IoTEdgeTimeout = 5; var facadeUri = this.faker.Internet.Url(); var facadeCode = this.faker.Internet.Password(); var twinProperty = new TwinProperties @@ -239,207 +209,65 @@ public async Task InitModuleAsync_Does_Not_Fail_When_Processing_Delay_Missing_Or })) }; - loRaModuleClient.Setup(x => x.GetTwinAsync(It.IsAny())).ReturnsAsync(new Twin(twinProperty)); + this.loRaModuleClient.Setup(x => x.GetTwinAsync(It.IsAny())).ReturnsAsync(new Twin(twinProperty)); - await using var moduleClient = new ModuleConnectionHost(networkServerConfiguration, classCMessageSender.Object, this.loRaModuleClientFactory.Object, loRaDeviceRegistry.Object, loRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance); - await moduleClient.CreateAsync(CancellationToken.None); + await this.subject.CreateAsync(CancellationToken.None); Assert.Equal(Constants.DefaultProcessingDelayInMilliseconds, networkServerConfiguration.ProcessingDelayInMilliseconds); } [Fact] public async Task InitModuleAsync_Fails_When_Fail_IoT_Hub_Communication() { - var networkServerConfiguration = new NetworkServerConfiguration(); - var classCMessageSender = new Mock(MockBehavior.Strict); - var loRaDeviceRegistry = new Mock(MockBehavior.Strict); - // Change the iot edge timeout. - networkServerConfiguration.IoTEdgeTimeout = 5; + this.networkServerConfiguration.IoTEdgeTimeout = 5; - loRaModuleClient.Setup(x => x.GetTwinAsync(It.IsAny())).Throws(); + this.loRaModuleClient.Setup(x => x.GetTwinAsync(It.IsAny())).Throws(); - await using var moduleClient = new ModuleConnectionHost(networkServerConfiguration, classCMessageSender.Object, this.loRaModuleClientFactory.Object, loRaDeviceRegistry.Object, loRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance); - var ex = await Assert.ThrowsAsync(() => moduleClient.CreateAsync(CancellationToken.None)); + var ex = await Assert.ThrowsAsync(() => this.subject.CreateAsync(CancellationToken.None)); Assert.Equal(LoRaProcessingErrorCode.TwinFetchFailed, ex.ErrorCode); } [Fact] - public async Task OnDirectMethodCall_ClearCache_When_Correct_Should_Work() + public async Task OnDirectMethodCall_Should_Invoke_ClearCache() { - var networkServerConfiguration = new NetworkServerConfiguration(); - var classCMessageSender = new Mock(MockBehavior.Strict); - var loRaDeviceRegistry = new Mock(MockBehavior.Strict); - loRaDeviceRegistry.Setup(x => x.ResetDeviceCacheAsync()).Returns(Task.CompletedTask); - - // Change the iot edge timeout. - networkServerConfiguration.IoTEdgeTimeout = 5; - - await using var moduleClient = new ModuleConnectionHost(networkServerConfiguration, classCMessageSender.Object, this.loRaModuleClientFactory.Object, loRaDeviceRegistry.Object, loRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance); - await moduleClient.OnDirectMethodCalled(new MethodRequest(Constants.CloudToDeviceClearCache), null); - loRaDeviceRegistry.VerifyAll(); + await this.subject.OnDirectMethodCalled(new MethodRequest(Constants.CloudToDeviceClearCache), null); + this.lnsRemoteCall.Verify(l => l.ClearCacheAsync(), Times.Once); } [Fact] - public async Task OnDirectMethodCall_DropConnection_Should_Work_As_Expected() - { - // arrange - var networkServerConfiguration = new NetworkServerConfiguration(); - var classCMessageSender = new Mock(MockBehavior.Strict); - var loRaDeviceRegistry = new Mock(MockBehavior.Strict); - var devEui = new DevEui(0); - var mockedDevice = new Mock(null, devEui, null); - _ = loRaDeviceRegistry.Setup(x => x.GetDeviceByDevEUIAsync(devEui)).ReturnsAsync(mockedDevice.Object); - var c2d = JsonSerializer.Serialize(new - { - DevEui = devEui.ToString(), - Fport = 1, - MessageId = Guid.NewGuid(), - }); - - // act - await using var moduleClient = new ModuleConnectionHost(networkServerConfiguration, classCMessageSender.Object, this.loRaModuleClientFactory.Object, loRaDeviceRegistry.Object, loRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance); - await moduleClient.OnDirectMethodCalled(new MethodRequest(Constants.CloudToDeviceCloseConnection, Encoding.UTF8.GetBytes(c2d)), null); - - // assert - loRaDeviceRegistry.VerifyAll(); - mockedDevice.Verify(x => x.CloseConnectionAsync(It.IsAny(), true), Times.Once); - } - - public static TheoryData DropConnectionInvalidMessages => - TheoryDataFactory.From( - (string.Empty, "Missing payload"), - ("null", "Missing payload"), - (JsonSerializer.Serialize(new { DevEui = (string)null, Fport = 1 }), "DevEUI missing"), - (JsonSerializer.Serialize(new { DevEui = new DevEui(0).ToString(), Fport = 1, MessageId = 123 }), "Unable to parse Json")); - - [Theory] - [MemberData(nameof(DropConnectionInvalidMessages))] - public async Task OnDirectMethodCall_DropConnection_Should_Return_Bad_Request_When_Invalid_Message(string json, string expectedLogPattern) + public async Task OnDirectMethodCall_Should_Invoke_DropConnection() { // arrange - var networkServerConfiguration = new NetworkServerConfiguration(); - var classCMessageSender = new Mock(MockBehavior.Strict); - var loRaDeviceRegistry = new Mock(MockBehavior.Strict); - var loggerMock = new Mock>(); + var json = @"{""foo"":""bar""}"; + var methodRequest = new MethodRequest(Constants.CloudToDeviceCloseConnection, Encoding.UTF8.GetBytes(json)); // act - await using var moduleClient = new ModuleConnectionHost(networkServerConfiguration, classCMessageSender.Object, this.loRaModuleClientFactory.Object, loRaDeviceRegistry.Object, loRaDeviceApiServiceBase, loggerMock.Object, TestMeter.Instance); - var response = await moduleClient.OnDirectMethodCalled(new MethodRequest(Constants.CloudToDeviceCloseConnection, Encoding.UTF8.GetBytes(json)), null); + await this.subject.OnDirectMethodCalled(methodRequest, null); // assert - Assert.Equal((int)HttpStatusCode.BadRequest, response.Status); - var log = Assert.Single(loggerMock.GetLogInvocations()); - Assert.Matches(expectedLogPattern, log.Message); - loRaDeviceRegistry.VerifyNoOtherCalls(); + this.lnsRemoteCall.Verify(l => l.CloseConnectionAsync(json, CancellationToken.None), Times.Once); } [Fact] - public async Task OnDirectMethodCall_DropConnection_Should_Return_NotFound_When_Device_Not_Found() + public async Task OnDirectMethodCall_Should_Invoke_SendCloudToDeviceMessageAsync() { // arrange - var networkServerConfiguration = new NetworkServerConfiguration(); - var classCMessageSender = new Mock(MockBehavior.Strict); - var loRaDeviceRegistry = new Mock(); - var devEui = new DevEui(0); - var c2d = JsonSerializer.Serialize(new { DevEui = devEui.ToString(), Fport = 1 }); + var json = @"{""foo"":""bar""}"; + var methodRequest = new MethodRequest(Constants.CloudToDeviceDecoderElementName, Encoding.UTF8.GetBytes(json)); // act - await using var moduleClient = new ModuleConnectionHost(networkServerConfiguration, classCMessageSender.Object, this.loRaModuleClientFactory.Object, loRaDeviceRegistry.Object, loRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance); - var response = await moduleClient.OnDirectMethodCalled(new MethodRequest(Constants.CloudToDeviceCloseConnection, Encoding.UTF8.GetBytes(c2d)), null); + var result = await this.subject.OnDirectMethodCalled(methodRequest, null); // assert - Assert.Equal((int)HttpStatusCode.NotFound, response.Status); - loRaDeviceRegistry.Verify(x => x.GetDeviceByDevEUIAsync(devEui), Times.Once); - loRaDeviceRegistry.VerifyNoOtherCalls(); - } - - [Fact] - public async Task OnDirectMethodCall_CloudToDeviceDecoderElementName_When_Correct_Should_Work() - { - var networkServerConfiguration = new NetworkServerConfiguration(); - var classCMessageSender = new Mock(MockBehavior.Strict); - classCMessageSender.Setup(x => x.SendAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); - var loRaDeviceRegistry = new Mock(MockBehavior.Strict); - - // Change the iot edge timeout. - networkServerConfiguration.IoTEdgeTimeout = 5; - - await using var moduleClient = new ModuleConnectionHost(networkServerConfiguration, classCMessageSender.Object, this.loRaModuleClientFactory.Object, loRaDeviceRegistry.Object, loRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance); - - var c2d = "{\"test\":\"asd\"}"; - - var response = await moduleClient.OnDirectMethodCalled(new MethodRequest(Constants.CloudToDeviceDecoderElementName, Encoding.UTF8.GetBytes(c2d), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5)), null); - Assert.Equal((int)HttpStatusCode.OK, response.Status); + this.lnsRemoteCall.Verify(l => l.SendCloudToDeviceMessageAsync(json, CancellationToken.None), Times.Once); } [Fact] public async Task OnDirectMethodCall_When_Null_Or_Empty_MethodName_Should_Throw() { - var networkServerConfiguration = new NetworkServerConfiguration(); - var classCMessageSender = new Mock(MockBehavior.Strict); - classCMessageSender.Setup(x => x.SendAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); - var loRaDeviceRegistry = new Mock(MockBehavior.Strict); - - // Change the iot edge timeout. - networkServerConfiguration.IoTEdgeTimeout = 5; - - await using var moduleClient = new ModuleConnectionHost(networkServerConfiguration, classCMessageSender.Object, this.loRaModuleClientFactory.Object, loRaDeviceRegistry.Object, loRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance); - - await Assert.ThrowsAnyAsync(async () => await moduleClient.OnDirectMethodCalled(null, null)); + await Assert.ThrowsAnyAsync(async () => await this.subject.OnDirectMethodCalled(null, null)); } - [Fact] - public async Task OnDirectMethodCall_CloudToDeviceDecoderElementName_When_Incorrect_Should_Return_NotFound() - { - var networkServerConfiguration = new NetworkServerConfiguration(); - var classCMessageSender = new Mock(MockBehavior.Strict); - classCMessageSender.Setup(x => x.SendAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); - var loRaDeviceRegistry = new Mock(MockBehavior.Strict); - - // Change the iot edge timeout. - networkServerConfiguration.IoTEdgeTimeout = 5; - - await using var moduleClient = new ModuleConnectionHost(networkServerConfiguration, classCMessageSender.Object, this.loRaModuleClientFactory.Object, loRaDeviceRegistry.Object, loRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance); - var c2d = "{\"test\":\"asd\"}"; - - var response = await moduleClient.OnDirectMethodCalled(new MethodRequest(this.faker.Random.String2(8), Encoding.UTF8.GetBytes(c2d)), null); - Assert.Equal((int)HttpStatusCode.BadRequest, response.Status); - } - - [Fact] - public async Task SendCloudToDeviceMessageAsync_When_ClassC_Msg_Is_Null_Or_Empty_Should_Return_Not_Found() - { - var networkServerConfiguration = new NetworkServerConfiguration(); - var classCMessageSender = new Mock(MockBehavior.Strict); - classCMessageSender.Setup(x => x.SendAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); - var loRaDeviceRegistry = new Mock(MockBehavior.Strict); - var loRaModuleClient = new Mock(); - loRaModuleClient.Setup(x => x.DisposeAsync()); - // Change the iot edge timeout. - networkServerConfiguration.IoTEdgeTimeout = 5; - await using var moduleClient = new ModuleConnectionHost(networkServerConfiguration, classCMessageSender.Object, this.loRaModuleClientFactory.Object, loRaDeviceRegistry.Object, loRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance); - - var response = await moduleClient.OnDirectMethodCalled(new MethodRequest(Constants.CloudToDeviceDecoderElementName, null), null); - Assert.Equal((int)HttpStatusCode.BadRequest, response.Status); - - var response2 = await moduleClient.OnDirectMethodCalled(new MethodRequest(Constants.CloudToDeviceDecoderElementName, Array.Empty()), null); - Assert.Equal((int)HttpStatusCode.BadRequest, response2.Status); - } - - [Fact] - public async Task SendCloudToDeviceMessageAsync_When_ClassC_Msg_Is_Not_CorrectJson_Should_Return_Not_Found() - { - var networkServerConfiguration = new NetworkServerConfiguration(); - var classCMessageSender = new Mock(MockBehavior.Strict); - classCMessageSender.Setup(x => x.SendAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); - var loRaDeviceRegistry = new Mock(MockBehavior.Strict); - - // Change the iot edge timeout. - networkServerConfiguration.IoTEdgeTimeout = 5; - await using var moduleClient = new ModuleConnectionHost(networkServerConfiguration, classCMessageSender.Object, this.loRaModuleClientFactory.Object, loRaDeviceRegistry.Object, loRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance); - - var response = await moduleClient.OnDirectMethodCalled(new MethodRequest(Constants.CloudToDeviceDecoderElementName, Encoding.UTF8.GetBytes(faker.Random.String2(10))), null); - Assert.Equal((int)HttpStatusCode.BadRequest, response.Status); - } + public async ValueTask DisposeAsync() => await this.subject.DisposeAsync(); } } From 5a01d02c5a932e4031899ce92fe6fdc2b8c97bed Mon Sep 17 00:00:00 2001 From: Daniele Antonio Maggio <1955514+danigian@users.noreply.github.com> Date: Tue, 17 May 2022 13:27:10 -0700 Subject: [PATCH 03/22] Adding EdgeDeviceGetter and unit tests (#1697) * Adding EdgeDeviceGetter and tests * Formatting and changing accessibility * Locking on redis cache * Adding to DI * Introducing cancellation token * Logging an error if we were not able to update redis * Adding prefix * Missed one statement * Adding timeout logic --- .../LoraKeysManagerFacade/EdgeDeviceGetter.cs | 118 ++++++++++++++++++ .../LoraKeysManagerFacade/FacadeStartup.cs | 1 + .../EdgeDeviceGetterTests.cs | 69 ++++++++++ 3 files changed, 188 insertions(+) create mode 100644 LoRaEngine/LoraKeysManagerFacade/EdgeDeviceGetter.cs create mode 100644 Tests/Unit/LoraKeysManagerFacade/EdgeDeviceGetterTests.cs diff --git a/LoRaEngine/LoraKeysManagerFacade/EdgeDeviceGetter.cs b/LoRaEngine/LoraKeysManagerFacade/EdgeDeviceGetter.cs new file mode 100644 index 0000000000..2cbdb4ce6b --- /dev/null +++ b/LoRaEngine/LoraKeysManagerFacade/EdgeDeviceGetter.cs @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace LoraKeysManagerFacade +{ + using System; + using System.Collections.Generic; + using System.Threading; + using System.Threading.Tasks; + using LoRaTools; + using Microsoft.Azure.Devices.Shared; + using Microsoft.Extensions.Logging; + + internal class EdgeDeviceGetter + { + private readonly IDeviceRegistryManager registryManager; + private readonly ILoRaDeviceCacheStore cacheStore; + private readonly ILogger logger; + private DateTimeOffset? lastUpdateTime; + + public EdgeDeviceGetter(IDeviceRegistryManager registryManager, + ILoRaDeviceCacheStore cacheStore, + ILogger logger) + { + this.registryManager = registryManager; + this.cacheStore = cacheStore; + this.logger = logger; + } + +#pragma warning disable IDE0060 // Remove unused parameter. Kept here for future improvements of RegistryManager + private async Task> GetEdgeDevicesAsync(CancellationToken cancellationToken) +#pragma warning restore IDE0060 // Remove unused parameter + { + this.logger.LogDebug("Getting Azure IoT Edge devices"); + var q = this.registryManager.CreateQuery("SELECT * FROM devices where capabilities.iotEdge = true"); + var twins = new List(); + do + { + twins.AddRange(await q.GetNextAsTwinAsync()); + } while (q.HasMoreResults); + return twins; + } + + internal async Task IsEdgeDeviceAsync(string lnsId, CancellationToken cancellationToken) + { + const string keyLock = $"{nameof(EdgeDeviceGetter)}-lock"; + const string owner = nameof(EdgeDeviceGetter); + var isEdgeDevice = false; + try + { + if (await this.cacheStore.LockTakeAsync(keyLock, owner, TimeSpan.FromSeconds(10))) + { + var findInCache = () => this.cacheStore.GetObject(RedisLnsDeviceCacheKey(lnsId)); + var firstSearch = findInCache(); + if (firstSearch is null) + { + await RefreshEdgeDevicesCacheAsync(cancellationToken); + isEdgeDevice = findInCache() is { IsEdge: true }; + if (!isEdgeDevice) + { + var marked = MarkDeviceAsNonEdge(lnsId); + if (!marked) + this.logger.LogError("Could not update Redis Edge Device cache status for device {}", lnsId); + } + } + else + { + return firstSearch.IsEdge; + } + } + else + { + throw new TimeoutException("Timed out while taking a lock on Redis Edge Device cache"); + } + } + finally + { + _ = this.cacheStore.LockRelease(keyLock, owner); + } + return isEdgeDevice; + } + + private static string RedisLnsDeviceCacheKey(string lnsId) => $"lnsInstance-{lnsId}"; + + private bool MarkDeviceAsNonEdge(string lnsId) + => this.cacheStore.ObjectSet(RedisLnsDeviceCacheKey(lnsId), + new DeviceKind(isEdge: false), + TimeSpan.FromDays(1), + onlyIfNotExists: true); + + private async Task RefreshEdgeDevicesCacheAsync(CancellationToken cancellationToken) + { + this.logger.LogDebug("Refreshing Azure IoT Edge devices cache"); + if (this.lastUpdateTime is null + || this.lastUpdateTime - DateTimeOffset.UtcNow >= TimeSpan.FromMinutes(1)) + { + var twins = await GetEdgeDevicesAsync(cancellationToken); + foreach (var t in twins) + { + _ = this.cacheStore.ObjectSet(RedisLnsDeviceCacheKey(t.DeviceId), + new DeviceKind(isEdge: true), + TimeSpan.FromDays(1), + onlyIfNotExists: true); + } + this.lastUpdateTime = DateTimeOffset.UtcNow; + } + } + } + + internal class DeviceKind + { + public bool IsEdge { get; private set; } + public DeviceKind(bool isEdge) + { + IsEdge = isEdge; + } + } +} diff --git a/LoRaEngine/LoraKeysManagerFacade/FacadeStartup.cs b/LoRaEngine/LoraKeysManagerFacade/FacadeStartup.cs index c51b9cae98..a20dd84a6f 100644 --- a/LoRaEngine/LoraKeysManagerFacade/FacadeStartup.cs +++ b/LoRaEngine/LoraKeysManagerFacade/FacadeStartup.cs @@ -60,6 +60,7 @@ public override void Configure(IFunctionsHostBuilder builder) sp.GetRequiredService>())) .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/Tests/Unit/LoraKeysManagerFacade/EdgeDeviceGetterTests.cs b/Tests/Unit/LoraKeysManagerFacade/EdgeDeviceGetterTests.cs new file mode 100644 index 0000000000..176206eb56 --- /dev/null +++ b/Tests/Unit/LoraKeysManagerFacade/EdgeDeviceGetterTests.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace LoRaWan.Tests.Unit.LoraKeysManagerFacade +{ + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + using global::LoraKeysManagerFacade; + using global::LoRaTools; + using Microsoft.Azure.Devices; + using Microsoft.Azure.Devices.Shared; + using Microsoft.Extensions.Logging.Abstractions; + using Moq; + using Xunit; + + public class EdgeDeviceGetterTests + { + private const string EdgeDevice1 = "edgeDevice1"; + private Mock mockRegistryManager; + private Mock mockQuery; + + public EdgeDeviceGetterTests() + { + InitRegistryManager(); + } + + [Theory] + [InlineData(EdgeDevice1, true)] + [InlineData("another", false)] + public async Task IsEdgeDeviceAsync_Returns_Proper_Answer(string lnsId, bool isEdge) + { + var edgeDeviceGetter = new EdgeDeviceGetter(InitRegistryManager(), new LoRaInMemoryDeviceStore(), NullLogger.Instance); + Assert.Equal(isEdge, await edgeDeviceGetter.IsEdgeDeviceAsync(lnsId, default)); + } + + [Fact] + public async Task IsEdgeDeviceAsync_Should_Not_Reach_IoTHub_Twice_If_Invoked_In_Less_Than_One_Minute() + { + var edgeDeviceGetter = new EdgeDeviceGetter(InitRegistryManager(), new LoRaInMemoryDeviceStore(), NullLogger.Instance); + Assert.True(await edgeDeviceGetter.IsEdgeDeviceAsync(EdgeDevice1, default)); + Assert.True(await edgeDeviceGetter.IsEdgeDeviceAsync(EdgeDevice1, default)); + Assert.False(await edgeDeviceGetter.IsEdgeDeviceAsync("anotherDevice", default)); + Assert.False(await edgeDeviceGetter.IsEdgeDeviceAsync("anotherDevice", default)); + + _ = this.mockQuery.Invocations.Single(x => x.Method.Name.Equals(nameof(IQuery.GetNextAsTwinAsync), System.StringComparison.OrdinalIgnoreCase)); + } + + private IDeviceRegistryManager InitRegistryManager() + { + this.mockQuery = new Mock(); + this.mockRegistryManager = new Mock(); + + var twins = new List() + { + new Twin(EdgeDevice1) { Capabilities = new DeviceCapabilities() { IotEdge = true }}, + }; + + mockQuery.Setup(x => x.GetNextAsTwinAsync()) + .ReturnsAsync(twins); + + mockRegistryManager + .Setup(x => x.CreateQuery(It.IsAny())) + .Returns(mockQuery.Object); + + return mockRegistryManager.Object; + } + } +} From a3445b7893197a502a54bed97b4d2bc2efaec3fc Mon Sep 17 00:00:00 2001 From: Andrew Doing Date: Tue, 17 May 2022 14:19:34 -0700 Subject: [PATCH 04/22] OCW2022 Decouple LoRaWAN Starter Kit from IoTEdge (#1698) * Introduce CLOUD_DEPLOYMENT variable #1627 * When not running as Edge module, ENABLE_GATEWAY can't be set to true #1628 * Make ProcessingDelayInMilliseconds configurable from environment variables #1629 * Fix unit tests * Add PR comment changes from bastbu and danigian --- .../BasicsStationNetworkServerStartup.cs | 5 +- .../NetworkServerConfiguration.cs | 13 +++- .../BasicsStationNetworkServerStartupTests.cs | 45 ++++++++++++++ Tests/Unit/NetworkServer/ConfigurationTest.cs | 60 +++++++++++++++++++ .../NetworkServer/ModuleConnectionHostTest.cs | 2 +- 5 files changed, 120 insertions(+), 5 deletions(-) diff --git a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/BasicsStationNetworkServerStartup.cs b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/BasicsStationNetworkServerStartup.cs index b8755b92b2..9b531048d5 100644 --- a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/BasicsStationNetworkServerStartup.cs +++ b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/BasicsStationNetworkServerStartup.cs @@ -79,7 +79,6 @@ public void ConfigureServices(IServiceCollection services) .AddHttpClient() .AddApiClient(NetworkServerConfiguration, ApiVersion.LatestVersion) .AddSingleton(NetworkServerConfiguration) - .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() @@ -125,6 +124,10 @@ public void ConfigureServices(IServiceCollection services) if (NetworkServerConfiguration.ClientCertificateMode is not ClientCertificateMode.NoCertificate) _ = services.AddSingleton(); + if (NetworkServerConfiguration.RunningAsIoTEdgeModule) + { + _ = services.AddSingleton(); + } } #pragma warning disable CA1822 // Mark members as static diff --git a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/NetworkServerConfiguration.cs b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/NetworkServerConfiguration.cs index d9b156a63f..970aa5bb67 100644 --- a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/NetworkServerConfiguration.cs +++ b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/NetworkServerConfiguration.cs @@ -135,6 +135,9 @@ public class NetworkServerConfiguration /// public uint IotHubConnectionPoolSize { get; internal set; } = 1; + /// + /// Specifies the Processing Delay in Milliseconds + /// public int ProcessingDelayInMilliseconds { get; set; } = Constants.DefaultProcessingDelayInMilliseconds; // Creates a new instance of NetworkServerConfiguration by reading values from environment variables @@ -144,11 +147,15 @@ public static NetworkServerConfiguration CreateFromEnvironmentVariables() // Create case insensitive dictionary from environment variables var envVars = new CaseInsensitiveEnvironmentVariables(Environment.GetEnvironmentVariables()); - - config.RunningAsIoTEdgeModule = !string.IsNullOrEmpty(envVars.GetEnvVar("IOTEDGE_APIVERSION", string.Empty)); + config.ProcessingDelayInMilliseconds = envVars.GetEnvVar("PROCESSING_DELAY_IN_MS", config.ProcessingDelayInMilliseconds); + config.RunningAsIoTEdgeModule = !envVars.GetEnvVar("CLOUD_DEPLOYMENT", false); config.IoTHubHostName = envVars.GetEnvVar("IOTEDGE_IOTHUBHOSTNAME", string.Empty); config.GatewayHostName = envVars.GetEnvVar("IOTEDGE_GATEWAYHOSTNAME", string.Empty); - config.EnableGateway = envVars.GetEnvVar("ENABLE_GATEWAY", config.EnableGateway); + config.EnableGateway = envVars.GetEnvVar("ENABLE_GATEWAY", true); + if (!config.RunningAsIoTEdgeModule && config.EnableGateway) + { + throw new NotSupportedException("ENABLE_GATEWAY cannot be true if RunningAsIoTEdgeModule is false."); + } config.GatewayID = envVars.GetEnvVar("IOTEDGE_DEVICEID", string.Empty); config.HttpsProxy = envVars.GetEnvVar("HTTPS_PROXY", string.Empty); config.Rx2DataRate = envVars.GetEnvVar("RX2_DATR", -1) is var datrNum && (DataRateIndex)datrNum is var datr && Enum.IsDefined(datr) ? datr : null; diff --git a/Tests/Unit/NetworkServer/BasicsStation/BasicsStationNetworkServerStartupTests.cs b/Tests/Unit/NetworkServer/BasicsStation/BasicsStationNetworkServerStartupTests.cs index 76ecb2383e..d9efab056d 100644 --- a/Tests/Unit/NetworkServer/BasicsStation/BasicsStationNetworkServerStartupTests.cs +++ b/Tests/Unit/NetworkServer/BasicsStation/BasicsStationNetworkServerStartupTests.cs @@ -3,7 +3,9 @@ namespace LoRaWan.Tests.Unit.NetworkServer.BasicsStation { + using System; using LoRaWan.NetworkServer.BasicsStation; + using LoRaWan.NetworkServer.BasicsStation.ModuleConnection; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -27,5 +29,48 @@ public void All_Dependencies_Are_Registered_Correctly() ValidateScopes = true }); } + + [Theory] + [InlineData(true, false)] + [InlineData(false, true)] + public void ModuleConnectionHostIsInjectedOrNot(bool cloud_deployment, bool enable_gateway) + { + var envVariables = new[] { ("CLOUD_DEPLOYMENT", cloud_deployment.ToString()), ("ENABLE_GATEWAY", enable_gateway.ToString()) }; + + try + { + foreach (var (key, value) in envVariables) + Environment.SetEnvironmentVariable(key, value); + + var services = new ServiceCollection(); + var config = new ConfigurationBuilder().Build(); + + // act + assert + var startup = new BasicsStationNetworkServerStartup(config); + startup.ConfigureServices(services); + + var serviceProvider = services.BuildServiceProvider(new ServiceProviderOptions + { + ValidateOnBuild = true, + ValidateScopes = true + }); + + var result = serviceProvider.GetService(); + if (cloud_deployment) + { + Assert.Null(result); + } + else + { + Assert.NotNull(result); + } + + } + finally + { + foreach (var (key, _) in envVariables) + Environment.SetEnvironmentVariable(key, string.Empty); + } + } } } diff --git a/Tests/Unit/NetworkServer/ConfigurationTest.cs b/Tests/Unit/NetworkServer/ConfigurationTest.cs index e1607fb7e0..8e6d0a28bb 100644 --- a/Tests/Unit/NetworkServer/ConfigurationTest.cs +++ b/Tests/Unit/NetworkServer/ConfigurationTest.cs @@ -5,7 +5,10 @@ namespace LoRaWan.Tests.Unit.NetworkServer { using System; using LoRaWan.NetworkServer; + using LoRaWan.NetworkServer.BasicsStation; using LoRaWan.Tests.Common; + using Microsoft.Extensions.Configuration; + using Microsoft.Extensions.DependencyInjection; using Xunit; public class ConfigurationTest @@ -39,5 +42,62 @@ public void Should_Setup_Allowed_Dev_Addresses_Correctly(string inputAllowedDevA TheoryDataFactory.From(("0228B1B1;", new[] { new DevAddr(0x0228b1b1) }), ("0228B1B1;0228B1B2", new DevAddr[] { new DevAddr(0x0228b1b1), new DevAddr(0x0228b1b2) }), ("ads;0228B1B2;", new DevAddr[] { new DevAddr(0x0228b1b2) })); + [Theory] + [CombinatorialData] + public void EnableGatewayTrue_IoTModuleFalse_IsNotSupported(bool cloud_deployment, bool enable_gateway) + { + var envVariables = new[] { ("CLOUD_DEPLOYMENT", cloud_deployment.ToString()), ("ENABLE_GATEWAY", enable_gateway.ToString()) }; + + try + { + foreach (var (key, value) in envVariables) + Environment.SetEnvironmentVariable(key, value); + + + + if (cloud_deployment && enable_gateway) + { + Assert.Throws(() => { + var networkServerConfiguration = NetworkServerConfiguration.CreateFromEnvironmentVariables(); + }); + } + else + { + var networkServerConfiguration = NetworkServerConfiguration.CreateFromEnvironmentVariables(); + } + } + finally + { + foreach (var (key, _) in envVariables) + Environment.SetEnvironmentVariable(key, string.Empty); + } + } + + [Theory] + [InlineData("500")] + [InlineData("x")] + public void ProcessingDelayIsConfigurable(string processing_delay) + { + var envVariables = new[] { ("PROCESSING_DELAY_IN_MS", processing_delay) }; + + try + { + foreach (var (key, value) in envVariables) + Environment.SetEnvironmentVariable(key, value); + + var networkServerConfiguration = NetworkServerConfiguration.CreateFromEnvironmentVariables(); + + if (!int.TryParse(processing_delay, out var int_processing_delay)) + { + int_processing_delay = Constants.DefaultProcessingDelayInMilliseconds; + } + Assert.Equal(int_processing_delay, networkServerConfiguration.ProcessingDelayInMilliseconds); + } + finally + { + foreach (var (key, _) in envVariables) + Environment.SetEnvironmentVariable(key, string.Empty); + } + } } } diff --git a/Tests/Unit/NetworkServer/ModuleConnectionHostTest.cs b/Tests/Unit/NetworkServer/ModuleConnectionHostTest.cs index 80574fe99f..5202ff57ca 100644 --- a/Tests/Unit/NetworkServer/ModuleConnectionHostTest.cs +++ b/Tests/Unit/NetworkServer/ModuleConnectionHostTest.cs @@ -34,7 +34,7 @@ public sealed class ModuleConnectionHostTest : IAsyncDisposable public ModuleConnectionHostTest() { - this.networkServerConfiguration = new NetworkServerConfiguration(); + this.networkServerConfiguration = NetworkServerConfiguration.CreateFromEnvironmentVariables(); this.loRaModuleClient.Setup(x => x.DisposeAsync()); this.loRaModuleClientFactory.Setup(x => x.CreateAsync()).ReturnsAsync(loRaModuleClient.Object); this.lnsRemoteCall = new Mock(); From 357a328e23f4b13ef3216c6c6d8cdc63c15d1492 Mon Sep 17 00:00:00 2001 From: Daniele Antonio Maggio <1955514+danigian@users.noreply.github.com> Date: Tue, 17 May 2022 14:54:43 -0700 Subject: [PATCH 05/22] Add Redis Connection String to LNS Configuration and StackExchange.Redis NuGet Package (#1693) * Adding StackExchange.Redis NuGet package * Adding RedisConnectionString environment variable to configuration * Adding a configuration unit test * Redis subscriber implementation (#1699) * Fixing injection * Making analyzer happy about null reference exceptions * Fixing UTs * Additional UT fix * Update LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/NetworkServerConfiguration.cs Co-authored-by: Bastian Burger <22341213+bastbu@users.noreply.github.com> Co-authored-by: Bastian Burger <22341213+bastbu@users.noreply.github.com> --- .../BasicsStationNetworkServerStartup.cs | 3 +- .../ModuleConnection/ModuleConnectionHost.cs | 28 +++++---- .../LoRaWan.NetworkServer/LnsRemoteCall.cs | 16 +++++ ...nsOperation.cs => LnsRemoteCallHandler.cs} | 41 +++++++------ .../LnsRemoteCallListener.cs | 33 ++++++++++ .../LoRaWan.NetworkServer.csproj | 1 + .../NetworkServerConfiguration.cs | 9 +++ Tests/Integration/RedisFixture.cs | 11 ++-- .../RedisRemoteCallListenerTests.cs | 61 +++++++++++++++++++ Tests/Simulation/SimulatedLoadTests.cs | 7 +-- .../BasicsStationNetworkServerStartupTests.cs | 7 ++- Tests/Unit/NetworkServer/ConfigurationTest.cs | 51 ++++++++++++++-- ...nTests.cs => LnsRemoteCallHandlerTests.cs} | 35 ++++++----- .../Unit/NetworkServer/LnsRemoteCallTests.cs | 27 ++++++++ .../NetworkServer/ModuleConnectionHostTest.cs | 13 ++-- 15 files changed, 273 insertions(+), 70 deletions(-) create mode 100644 LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LnsRemoteCall.cs rename LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/{BasicsStation/LnsOperation.cs => LnsRemoteCallHandler.cs} (71%) create mode 100644 LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LnsRemoteCallListener.cs create mode 100644 Tests/Integration/RedisRemoteCallListenerTests.cs rename Tests/Unit/NetworkServer/{LnsOperationTests.cs => LnsRemoteCallHandlerTests.cs} (78%) create mode 100644 Tests/Unit/NetworkServer/LnsRemoteCallTests.cs diff --git a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/BasicsStationNetworkServerStartup.cs b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/BasicsStationNetworkServerStartup.cs index 9b531048d5..0f0b9413a1 100644 --- a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/BasicsStationNetworkServerStartup.cs +++ b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/BasicsStationNetworkServerStartup.cs @@ -14,6 +14,7 @@ namespace LoRaWan.NetworkServer.BasicsStation using LoRaTools.CommonAPI; using LoRaTools.NetworkServerDiscovery; using LoRaWan; + using LoRaWan.NetworkServer; using LoRaWan.NetworkServer.ADR; using LoRaWan.NetworkServer.BasicsStation.ModuleConnection; using LoRaWan.NetworkServer.BasicsStation.Processors; @@ -79,7 +80,7 @@ public void ConfigureServices(IServiceCollection services) .AddHttpClient() .AddApiClient(NetworkServerConfiguration, ApiVersion.LatestVersion) .AddSingleton(NetworkServerConfiguration) - .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/ModuleConnection/ModuleConnectionHost.cs b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/ModuleConnection/ModuleConnectionHost.cs index ecc18cd2a9..3f1257f37b 100644 --- a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/ModuleConnection/ModuleConnectionHost.cs +++ b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/ModuleConnection/ModuleConnectionHost.cs @@ -4,6 +4,7 @@ namespace LoRaWan.NetworkServer.BasicsStation.ModuleConnection { using LoRaTools.Utils; + using LoRaWan.NetworkServer; using Microsoft.Azure.Devices.Client; using Microsoft.Azure.Devices.Client.Exceptions; using Microsoft.Azure.Devices.Shared; @@ -11,7 +12,6 @@ namespace LoRaWan.NetworkServer.BasicsStation.ModuleConnection using System; using System.Configuration; using System.Diagnostics.Metrics; - using System.Net; using System.Threading; using System.Threading.Tasks; @@ -20,7 +20,7 @@ internal sealed class ModuleConnectionHost : IAsyncDisposable private const string LnsVersionPropertyName = "LnsVersion"; private readonly NetworkServerConfiguration networkServerConfiguration; private readonly LoRaDeviceAPIServiceBase loRaDeviceAPIService; - private readonly ILnsRemoteCall lnsRemoteCall; + private readonly ILnsRemoteCallHandler lnsRemoteCallHandler; private readonly ILogger logger; private readonly Counter unhandledExceptionCount; private ILoraModuleClient loRaModuleClient; @@ -30,13 +30,13 @@ public ModuleConnectionHost( NetworkServerConfiguration networkServerConfiguration, ILoRaModuleClientFactory loRaModuleClientFactory, LoRaDeviceAPIServiceBase loRaDeviceAPIService, - ILnsRemoteCall lnsRemoteCall, + ILnsRemoteCallHandler lnsRemoteCallHandler, ILogger logger, Meter meter) { this.networkServerConfiguration = networkServerConfiguration ?? throw new ArgumentNullException(nameof(networkServerConfiguration)); this.loRaDeviceAPIService = loRaDeviceAPIService ?? throw new ArgumentNullException(nameof(loRaDeviceAPIService)); - this.lnsRemoteCall = lnsRemoteCall ?? throw new ArgumentNullException(nameof(lnsRemoteCall)); + this.lnsRemoteCallHandler = lnsRemoteCallHandler ?? throw new ArgumentNullException(nameof(lnsRemoteCallHandler)); this.loRaModuleClientFactory = loRaModuleClientFactory ?? throw new ArgumentNullException(nameof(loRaModuleClientFactory)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.unhandledExceptionCount = (meter ?? throw new ArgumentNullException(nameof(meter))).CreateCounter(MetricRegistry.UnhandledExceptions); @@ -91,31 +91,33 @@ internal async Task OnDirectMethodCalled(MethodRequest methodReq using var cts = methodRequest.ResponseTimeout is { } someResponseTimeout ? new CancellationTokenSource(someResponseTimeout) : null; var token = cts?.Token ?? CancellationToken.None; + // Mapping via the constants for backwards compatibility. + LnsRemoteCall lnsRemoteCall; if (string.Equals(Constants.CloudToDeviceClearCache, methodRequest.Name, StringComparison.OrdinalIgnoreCase)) { - return AsMethodResponse(await this.lnsRemoteCall.ClearCacheAsync()); + lnsRemoteCall = new LnsRemoteCall(RemoteCallKind.ClearCache, null); } else if (string.Equals(Constants.CloudToDeviceCloseConnection, methodRequest.Name, StringComparison.OrdinalIgnoreCase)) { - return AsMethodResponse(await this.lnsRemoteCall.CloseConnectionAsync(methodRequest.DataAsJson, token)); + lnsRemoteCall = new LnsRemoteCall(RemoteCallKind.CloseConnection, methodRequest.DataAsJson); } else if (string.Equals(Constants.CloudToDeviceDecoderElementName, methodRequest.Name, StringComparison.OrdinalIgnoreCase)) { - return AsMethodResponse(await this.lnsRemoteCall.SendCloudToDeviceMessageAsync(methodRequest.DataAsJson, token)); + lnsRemoteCall = new LnsRemoteCall(RemoteCallKind.CloudToDeviceMessage, methodRequest.DataAsJson); + } + else + { + throw new LoRaProcessingException($"Unknown direct method called: {methodRequest.Name}"); } - this.logger.LogError($"Unknown direct method called: {methodRequest.Name}"); - - return AsMethodResponse(HttpStatusCode.BadRequest); + var statusCode = await lnsRemoteCallHandler.ExecuteAsync(lnsRemoteCall, token); + return new MethodResponse((int)statusCode); } catch (Exception ex) when (ExceptionFilterUtility.False(() => this.logger.LogError(ex, $"An exception occurred on a direct method call: {ex}"), () => this.unhandledExceptionCount.Add(1))) { throw; } - - static MethodResponse AsMethodResponse(HttpStatusCode httpStatusCode) => - new MethodResponse((int)httpStatusCode); } /// diff --git a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LnsRemoteCall.cs b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LnsRemoteCall.cs new file mode 100644 index 0000000000..49dd843800 --- /dev/null +++ b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LnsRemoteCall.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#nullable enable + +namespace LoRaWan.NetworkServer +{ + internal sealed record LnsRemoteCall(RemoteCallKind Kind, string? JsonData); + + internal enum RemoteCallKind + { + CloudToDeviceMessage, + ClearCache, + CloseConnection + } +} diff --git a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/LnsOperation.cs b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LnsRemoteCallHandler.cs similarity index 71% rename from LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/LnsOperation.cs rename to LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LnsRemoteCallHandler.cs index 78ba970a09..ba4eb87591 100644 --- a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/LnsOperation.cs +++ b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LnsRemoteCallHandler.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -namespace LoRaWan.NetworkServer.BasicsStation +namespace LoRaWan.NetworkServer { using System.Diagnostics.Metrics; using System.Net; @@ -11,14 +11,12 @@ namespace LoRaWan.NetworkServer.BasicsStation using LoRaTools; using Microsoft.Extensions.Logging; - internal interface ILnsRemoteCall + internal interface ILnsRemoteCallHandler { - Task ClearCacheAsync(); - Task CloseConnectionAsync(string json, CancellationToken cancellationToken); - Task SendCloudToDeviceMessageAsync(string json, CancellationToken cancellationToken); + Task ExecuteAsync(LnsRemoteCall lnsRemoteCall, CancellationToken cancellationToken); } - internal sealed class LnsRemoteCall : ILnsRemoteCall + internal sealed class LnsRemoteCallHandler : ILnsRemoteCallHandler { internal const string ClosedConnectionLog = "Device connection was closed "; private static readonly JsonSerializerOptions JsonSerializerOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; @@ -26,14 +24,14 @@ internal sealed class LnsRemoteCall : ILnsRemoteCall private readonly NetworkServerConfiguration networkServerConfiguration; private readonly IClassCDeviceMessageSender classCDeviceMessageSender; private readonly ILoRaDeviceRegistry loRaDeviceRegistry; - private readonly ILogger logger; + private readonly ILogger logger; private readonly Counter forceClosedConnections; - public LnsRemoteCall(NetworkServerConfiguration networkServerConfiguration, - IClassCDeviceMessageSender classCDeviceMessageSender, - ILoRaDeviceRegistry loRaDeviceRegistry, - ILogger logger, - Meter meter) + public LnsRemoteCallHandler(NetworkServerConfiguration networkServerConfiguration, + IClassCDeviceMessageSender classCDeviceMessageSender, + ILoRaDeviceRegistry loRaDeviceRegistry, + ILogger logger, + Meter meter) { this.networkServerConfiguration = networkServerConfiguration; this.classCDeviceMessageSender = classCDeviceMessageSender; @@ -42,7 +40,18 @@ public LnsRemoteCall(NetworkServerConfiguration networkServerConfiguration, this.forceClosedConnections = meter.CreateCounter(MetricRegistry.ForceClosedClientConnections); } - public async Task SendCloudToDeviceMessageAsync(string json, CancellationToken cancellationToken) + public Task ExecuteAsync(LnsRemoteCall lnsRemoteCall, CancellationToken cancellationToken) + { + return lnsRemoteCall.Kind switch + { + RemoteCallKind.CloudToDeviceMessage => SendCloudToDeviceMessageAsync(lnsRemoteCall.JsonData, cancellationToken), + RemoteCallKind.ClearCache => ClearCacheAsync(), + RemoteCallKind.CloseConnection => CloseConnectionAsync(lnsRemoteCall.JsonData, cancellationToken), + _ => throw new System.NotImplementedException(), + }; + } + + private async Task SendCloudToDeviceMessageAsync(string json, CancellationToken cancellationToken) { if (!string.IsNullOrEmpty(json)) { @@ -62,15 +71,13 @@ public async Task SendCloudToDeviceMessageAsync(string json, Can this.logger.LogDebug($"received cloud to device message from direct method: {json}"); if (await this.classCDeviceMessageSender.SendAsync(c2d, cancellationToken)) - { return HttpStatusCode.OK; - } } return HttpStatusCode.BadRequest; } - public async Task CloseConnectionAsync(string json, CancellationToken cancellationToken) + private async Task CloseConnectionAsync(string json, CancellationToken cancellationToken) { ReceivedLoRaCloudToDeviceMessage c2d; @@ -114,7 +121,7 @@ public async Task CloseConnectionAsync(string json, Cancellation return HttpStatusCode.OK; } - public async Task ClearCacheAsync() + private async Task ClearCacheAsync() { await this.loRaDeviceRegistry.ResetDeviceCacheAsync(); return HttpStatusCode.OK; diff --git a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LnsRemoteCallListener.cs b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LnsRemoteCallListener.cs new file mode 100644 index 0000000000..20a0e29535 --- /dev/null +++ b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LnsRemoteCallListener.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#nullable enable + +namespace LoRaWan.NetworkServer +{ + using System; + using System.Text.Json; + using System.Threading.Tasks; + using StackExchange.Redis; + + internal interface ILnsRemoteCallListener + { + void Subscribe(string lns, Func function); + } + + internal sealed class RedisRemoteCallListener : ILnsRemoteCallListener + { + private readonly ConnectionMultiplexer redis; + + public RedisRemoteCallListener(ConnectionMultiplexer redis) + { + this.redis = redis; + } + + public void Subscribe(string lns, Func function) + { + this.redis.GetSubscriber().Subscribe(lns).OnMessage(value => + function(JsonSerializer.Deserialize(value.Message) ?? throw new ArgumentException("Input LnsRemoteCall json was not parsed as valid one."))); + } + } +} diff --git a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LoRaWan.NetworkServer.csproj b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LoRaWan.NetworkServer.csproj index 78f7cd2622..4040a1dfdc 100644 --- a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LoRaWan.NetworkServer.csproj +++ b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LoRaWan.NetworkServer.csproj @@ -22,6 +22,7 @@ + diff --git a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/NetworkServerConfiguration.cs b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/NetworkServerConfiguration.cs index 970aa5bb67..2999c6ba73 100644 --- a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/NetworkServerConfiguration.cs +++ b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/NetworkServerConfiguration.cs @@ -131,6 +131,11 @@ public class NetworkServerConfiguration /// public string LnsVersion { get; private set; } + /// + /// Gets the connection string of Redis server for Pub/Sub functionality in Cloud only deployments. + /// + public string RedisConnectionString { get; private set; } + /// Specifies the pool size for upstream AMQP connection /// public uint IotHubConnectionPoolSize { get; internal set; } = 1; @@ -191,6 +196,10 @@ public static NetworkServerConfiguration CreateFromEnvironmentVariables() ? size : throw new NotSupportedException($"'IOTHUB_CONNECTION_POOL_SIZE' needs to be between 1 and {AmqpConnectionPoolSettings.AbsoluteMaxPoolSize}."); + config.RedisConnectionString = envVars.GetEnvVar("REDIS_CONNECTION_STRING", string.Empty); + if (!config.RunningAsIoTEdgeModule && string.IsNullOrEmpty(config.RedisConnectionString)) + throw new InvalidOperationException("'REDIS_CONNECTION_STRING' can't be empty if running network server as part of a cloud only deployment."); + return config; } } diff --git a/Tests/Integration/RedisFixture.cs b/Tests/Integration/RedisFixture.cs index e80bec551b..f71cfb619a 100644 --- a/Tests/Integration/RedisFixture.cs +++ b/Tests/Integration/RedisFixture.cs @@ -27,11 +27,10 @@ public class RedisFixture : IAsyncLifetime private const int RedisPort = 6001; private static readonly string TestContainerName = ContainerName + RedisPort; - private ConnectionMultiplexer redis; - private string containerId; public IDatabase Database { get; set; } + public ConnectionMultiplexer Redis { get; set; } private async Task StartRedisContainer() { @@ -127,8 +126,8 @@ public async Task InitializeAsync() var redisConnectionString = $"localhost:{RedisPort}"; try { - this.redis = await ConnectionMultiplexer.ConnectAsync(redisConnectionString); - Database = this.redis.GetDatabase(); + this.Redis = await ConnectionMultiplexer.ConnectAsync(redisConnectionString); + Database = this.Redis.GetDatabase(); } catch (Exception ex) { @@ -157,8 +156,8 @@ public async Task DisposeAsync() } } - this.redis?.Dispose(); - this.redis = null; + this.Redis?.Dispose(); + this.Redis = null; } } } diff --git a/Tests/Integration/RedisRemoteCallListenerTests.cs b/Tests/Integration/RedisRemoteCallListenerTests.cs new file mode 100644 index 0000000000..f21dbab671 --- /dev/null +++ b/Tests/Integration/RedisRemoteCallListenerTests.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace LoRaWan.Tests.Integration +{ + using System; + using System.Text.Json; + using System.Threading.Tasks; + using LoRaWan.NetworkServer; + using Moq; + using StackExchange.Redis; + using Xunit; + + [Collection(RedisFixture.CollectionName)] + public sealed class RedisRemoteCallListenerTests : IClassFixture + { + private readonly ConnectionMultiplexer redis; + private readonly RedisRemoteCallListener subject; + + public RedisRemoteCallListenerTests(RedisFixture redisFixture) + { + this.redis = redisFixture.Redis; + this.subject = new RedisRemoteCallListener(this.redis); + } + + [Fact] + public async Task Subscribe_Receives_Message() + { + // arrange + var lnsName = "some-lns"; + var remoteCall = new LnsRemoteCall(RemoteCallKind.CloudToDeviceMessage, "somejsondata"); + var function = new Mock>(); + + // act + this.subject.Subscribe(lnsName, function.Object); + await PublishAsync(lnsName, remoteCall); + + // assert + function.Verify(a => a.Invoke(remoteCall), Times.Once); + } + + [Fact] + public async Task Subscribe_On_Different_Channel_Does_Not_Receive_Message() + { + // arrange + var function = new Mock>(); + + // act + this.subject.Subscribe("lns-1", function.Object); + await PublishAsync("lns-2", new LnsRemoteCall(RemoteCallKind.CloudToDeviceMessage, null)); + + // assert + function.Verify(a => a.Invoke(It.IsAny()), Times.Never); + } + + private async Task PublishAsync(string channel, LnsRemoteCall lnsRemoteCall) + { + await this.redis.GetSubscriber().PublishAsync(channel, JsonSerializer.Serialize(lnsRemoteCall)); + } + } +} diff --git a/Tests/Simulation/SimulatedLoadTests.cs b/Tests/Simulation/SimulatedLoadTests.cs index eb61160a7d..263a9200f1 100644 --- a/Tests/Simulation/SimulatedLoadTests.cs +++ b/Tests/Simulation/SimulatedLoadTests.cs @@ -22,7 +22,6 @@ namespace LoRaWan.Tests.Simulation using static MoreLinq.Extensions.RepeatExtension; using static MoreLinq.Extensions.IndexExtension; using static MoreLinq.Extensions.TransposeExtension; - using LoRaWan.NetworkServer.BasicsStation; [Trait("Category", "SkipWhenLiveUnitTesting")] public sealed class SimulatedLoadTests : IntegrationTestBaseSim, IAsyncLifetime @@ -101,7 +100,7 @@ public async Task Ensures_Disconnect_Happens_For_Losing_Gateway_When_Connection_ await Task.Delay(messagesToSendEachLNS * IntervalBetweenMessages); _ = await TestFixture.AssertNetworkServerModuleLogExistsAsync( - x => !x.Contains(LnsRemoteCall.ClosedConnectionLog, StringComparison.Ordinal), + x => !x.Contains(LnsRemoteCallHandler.ClosedConnectionLog, StringComparison.Ordinal), new SearchLogOptions("No connection switch should be logged") { TreatAsError = true }); // act: change basics station that the device is listened from and therefore the gateway it uses as well @@ -111,8 +110,8 @@ public async Task Ensures_Disconnect_Happens_For_Losing_Gateway_When_Connection_ // assert var expectedLnsToDropConnection = Configuration.LnsEndpointsForSimulator.First().Key; _ = await TestFixture.AssertNetworkServerModuleLogExistsAsync( - x => x.Contains(LnsRemoteCall.ClosedConnectionLog, StringComparison.Ordinal) && x.Contains(expectedLnsToDropConnection, StringComparison.Ordinal), - new SearchLogOptions($"{LnsRemoteCall.ClosedConnectionLog} and {expectedLnsToDropConnection}") { TreatAsError = true }); + x => x.Contains(LnsRemoteCallHandler.ClosedConnectionLog, StringComparison.Ordinal) && x.Contains(expectedLnsToDropConnection, StringComparison.Ordinal), + new SearchLogOptions($"{LnsRemoteCallHandler.ClosedConnectionLog} and {expectedLnsToDropConnection}") { TreatAsError = true }); await AssertIotHubMessageCountAsync(simulatedDevice, messagesToSendEachLNS * 2); } diff --git a/Tests/Unit/NetworkServer/BasicsStation/BasicsStationNetworkServerStartupTests.cs b/Tests/Unit/NetworkServer/BasicsStation/BasicsStationNetworkServerStartupTests.cs index d9efab056d..eeddcad3fa 100644 --- a/Tests/Unit/NetworkServer/BasicsStation/BasicsStationNetworkServerStartupTests.cs +++ b/Tests/Unit/NetworkServer/BasicsStation/BasicsStationNetworkServerStartupTests.cs @@ -35,7 +35,12 @@ public void All_Dependencies_Are_Registered_Correctly() [InlineData(false, true)] public void ModuleConnectionHostIsInjectedOrNot(bool cloud_deployment, bool enable_gateway) { - var envVariables = new[] { ("CLOUD_DEPLOYMENT", cloud_deployment.ToString()), ("ENABLE_GATEWAY", enable_gateway.ToString()) }; + var envVariables = new[] + { + ("CLOUD_DEPLOYMENT", cloud_deployment.ToString()), + ("ENABLE_GATEWAY", enable_gateway.ToString()), + ("REDIS_CONNECTION_STRING", "someString") + }; try { diff --git a/Tests/Unit/NetworkServer/ConfigurationTest.cs b/Tests/Unit/NetworkServer/ConfigurationTest.cs index 8e6d0a28bb..35e2bb0618 100644 --- a/Tests/Unit/NetworkServer/ConfigurationTest.cs +++ b/Tests/Unit/NetworkServer/ConfigurationTest.cs @@ -5,10 +5,7 @@ namespace LoRaWan.Tests.Unit.NetworkServer { using System; using LoRaWan.NetworkServer; - using LoRaWan.NetworkServer.BasicsStation; using LoRaWan.Tests.Common; - using Microsoft.Extensions.Configuration; - using Microsoft.Extensions.DependencyInjection; using Xunit; public class ConfigurationTest @@ -38,23 +35,65 @@ public void Should_Setup_Allowed_Dev_Addresses_Correctly(string inputAllowedDevA } } + [Theory] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + public void Should_Throw_On_Invalid_Cloud_Configuration_When_Redis_Connection_String_Not_Set(bool shouldSetRedisString, bool isCloudDeployment) + { + // arrange + var cloudDeploymentKey = "CLOUD_DEPLOYMENT"; + var key = "REDIS_CONNECTION_STRING"; + var value = "someValue"; + var lnsConfigurationCreation = () => NetworkServerConfiguration.CreateFromEnvironmentVariables(); + + if (isCloudDeployment) + { + Environment.SetEnvironmentVariable(cloudDeploymentKey, true.ToString()); + Environment.SetEnvironmentVariable("ENABLE_GATEWAY", false.ToString()); + } + + if (shouldSetRedisString) + Environment.SetEnvironmentVariable(key, value); + + // act and assert + if (isCloudDeployment && !shouldSetRedisString) + { + _ = Assert.Throws(lnsConfigurationCreation); + } + else + { + _ = lnsConfigurationCreation(); + } + + Environment.SetEnvironmentVariable(key, string.Empty); + Environment.SetEnvironmentVariable(cloudDeploymentKey, string.Empty); + Environment.SetEnvironmentVariable("ENABLE_GATEWAY", string.Empty); + } + + public static TheoryData AllowedDevAddressesInput => TheoryDataFactory.From(("0228B1B1;", new[] { new DevAddr(0x0228b1b1) }), ("0228B1B1;0228B1B2", new DevAddr[] { new DevAddr(0x0228b1b1), new DevAddr(0x0228b1b2) }), ("ads;0228B1B2;", new DevAddr[] { new DevAddr(0x0228b1b2) })); + [Theory] [CombinatorialData] public void EnableGatewayTrue_IoTModuleFalse_IsNotSupported(bool cloud_deployment, bool enable_gateway) { - var envVariables = new[] { ("CLOUD_DEPLOYMENT", cloud_deployment.ToString()), ("ENABLE_GATEWAY", enable_gateway.ToString()) }; + var envVariables = new[] + { + ("CLOUD_DEPLOYMENT", cloud_deployment.ToString()), + ("ENABLE_GATEWAY", enable_gateway.ToString()), + ("REDIS_CONNECTION_STRING", "someString") + }; try { foreach (var (key, value) in envVariables) Environment.SetEnvironmentVariable(key, value); - - if (cloud_deployment && enable_gateway) { Assert.Throws(() => { diff --git a/Tests/Unit/NetworkServer/LnsOperationTests.cs b/Tests/Unit/NetworkServer/LnsRemoteCallHandlerTests.cs similarity index 78% rename from Tests/Unit/NetworkServer/LnsOperationTests.cs rename to Tests/Unit/NetworkServer/LnsRemoteCallHandlerTests.cs index 3cc5ef64df..c9a05f23c4 100644 --- a/Tests/Unit/NetworkServer/LnsOperationTests.cs +++ b/Tests/Unit/NetworkServer/LnsRemoteCallHandlerTests.cs @@ -10,28 +10,27 @@ namespace LoRaWan.Tests.Unit.NetworkServer using System.Threading.Tasks; using Bogus; using LoRaWan.NetworkServer; - using LoRaWan.NetworkServer.BasicsStation; using LoRaWan.Tests.Common; using Microsoft.Extensions.Logging; using Moq; using Xunit; - public sealed class LnsRemoteCallTests + public sealed class LnsRemoteCallHandlerTests { private readonly Faker faker = new(); private readonly NetworkServerConfiguration networkServerConfiguration; private readonly Mock classCMessageSender; private readonly Mock loRaDeviceRegistry; - private readonly Mock> logger; - private readonly LnsRemoteCall subject; + private readonly Mock> logger; + private readonly LnsRemoteCallHandler subject; - public LnsRemoteCallTests() + public LnsRemoteCallHandlerTests() { this.networkServerConfiguration = new NetworkServerConfiguration(); this.classCMessageSender = new Mock(); this.loRaDeviceRegistry = new Mock(); - this.logger = new Mock>(); - this.subject = new LnsRemoteCall(this.networkServerConfiguration, + this.logger = new Mock>(); + this.subject = new LnsRemoteCallHandler(this.networkServerConfiguration, this.classCMessageSender.Object, this.loRaDeviceRegistry.Object, this.logger.Object, @@ -40,7 +39,7 @@ public LnsRemoteCallTests() [Fact] - public async Task OnDirectMethodCall_DropConnection_Should_Work_As_Expected() + public async Task CloseConnectionAsync_Should_Work_As_Expected() { // arrange var devEui = new DevEui(0); @@ -54,7 +53,7 @@ public async Task OnDirectMethodCall_DropConnection_Should_Work_As_Expected() }); // act - _ = await this.subject.CloseConnectionAsync(c2d, CancellationToken.None); + _ = await CloseConnectionAsync(c2d); // assert this.loRaDeviceRegistry.VerifyAll(); @@ -69,7 +68,7 @@ public async Task ClearCache_When_Correct_Should_Work() this.networkServerConfiguration.IoTEdgeTimeout = 5; // act - await this.subject.ClearCacheAsync(); + await this.subject.ExecuteAsync(new LnsRemoteCall(RemoteCallKind.ClearCache, null), CancellationToken.None); // assert this.loRaDeviceRegistry.VerifyAll(); @@ -87,7 +86,7 @@ public async Task ClearCache_When_Correct_Should_Work() public async Task CloseConnectionAsync_Should_Return_Bad_Request_When_Invalid_Message(string json, string expectedLogPattern) { // act - var response = await this.subject.CloseConnectionAsync(json, CancellationToken.None); + var response = await CloseConnectionAsync(json); // assert Assert.Equal(HttpStatusCode.BadRequest, response); @@ -104,7 +103,7 @@ public async Task CloseConnectionAsync_Should_Return_NotFound_When_Device_Not_Fo var c2d = JsonSerializer.Serialize(new { DevEui = devEui.ToString(), Fport = 1 }); // act - var response = await this.subject.CloseConnectionAsync(c2d, CancellationToken.None); + var response = await CloseConnectionAsync(c2d); // assert Assert.Equal(HttpStatusCode.NotFound, response); @@ -120,7 +119,7 @@ public async Task SendCloudToDeviceMessageAsync_When_Correct_Should_Work() var c2d = "{\"test\":\"asd\"}"; // act - var response = await this.subject.SendCloudToDeviceMessageAsync(c2d, CancellationToken.None); + var response = await SendCloudToDeviceMessageAsync(c2d); // assert Assert.Equal(HttpStatusCode.OK, response); @@ -133,15 +132,21 @@ public async Task SendCloudToDeviceMessageAsync_When_ClassC_Msg_Is_Null_Or_Empty { this.classCMessageSender.Setup(x => x.SendAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); - var response = await this.subject.SendCloudToDeviceMessageAsync(json, CancellationToken.None); + var response = await SendCloudToDeviceMessageAsync(json); Assert.Equal(HttpStatusCode.BadRequest, response); } [Fact] public async Task SendCloudToDeviceMessageAsync_When_ClassC_Msg_Is_Not_CorrectJson_Should_Return_Not_Found() { - var response = await this.subject.SendCloudToDeviceMessageAsync(this.faker.Random.String2(10), CancellationToken.None); + var response = await SendCloudToDeviceMessageAsync(this.faker.Random.String2(10)); Assert.Equal(HttpStatusCode.BadRequest, response); } + + private Task CloseConnectionAsync(string payload) => + this.subject.ExecuteAsync(new LnsRemoteCall(RemoteCallKind.CloseConnection, payload), CancellationToken.None); + + private Task SendCloudToDeviceMessageAsync(string payload) => + this.subject.ExecuteAsync(new LnsRemoteCall(RemoteCallKind.CloudToDeviceMessage, payload), CancellationToken.None); } } diff --git a/Tests/Unit/NetworkServer/LnsRemoteCallTests.cs b/Tests/Unit/NetworkServer/LnsRemoteCallTests.cs new file mode 100644 index 0000000000..506e2d7abe --- /dev/null +++ b/Tests/Unit/NetworkServer/LnsRemoteCallTests.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#nullable enable + +namespace LoRaWan.Tests.Unit.NetworkServer +{ + using System.Text.Json; + using LoRaWan.NetworkServer; + using Xunit; + + public sealed class LnsRemoteCallTests + { + [Fact] + public void Serialization_And_Deserialization_Preserves_Information() + { + // arrange + var subject = new LnsRemoteCall(RemoteCallKind.CloudToDeviceMessage, "somepayload"); + + // act + var result = JsonSerializer.Deserialize(JsonSerializer.Serialize(subject)); + + // assert + Assert.Equal(subject, result); + } + } +} diff --git a/Tests/Unit/NetworkServer/ModuleConnectionHostTest.cs b/Tests/Unit/NetworkServer/ModuleConnectionHostTest.cs index 5202ff57ca..bf16478f8e 100644 --- a/Tests/Unit/NetworkServer/ModuleConnectionHostTest.cs +++ b/Tests/Unit/NetworkServer/ModuleConnectionHostTest.cs @@ -5,7 +5,6 @@ namespace LoRaWan.Tests.Unit.NetworkServer { using Bogus; using LoRaWan.NetworkServer; - using LoRaWan.NetworkServer.BasicsStation; using LoRaWan.NetworkServer.BasicsStation.ModuleConnection; using LoRaWan.Tests.Common; using Microsoft.Azure.Devices.Client; @@ -29,7 +28,7 @@ public sealed class ModuleConnectionHostTest : IAsyncDisposable private readonly Mock loRaModuleClient = new(); private readonly LoRaDeviceAPIServiceBase loRaDeviceApiServiceBase = Mock.Of(); private readonly Faker faker = new Faker(); - private readonly Mock lnsRemoteCall; + private readonly Mock lnsRemoteCall; private readonly ModuleConnectionHost subject; public ModuleConnectionHostTest() @@ -37,7 +36,7 @@ public ModuleConnectionHostTest() this.networkServerConfiguration = NetworkServerConfiguration.CreateFromEnvironmentVariables(); this.loRaModuleClient.Setup(x => x.DisposeAsync()); this.loRaModuleClientFactory.Setup(x => x.CreateAsync()).ReturnsAsync(loRaModuleClient.Object); - this.lnsRemoteCall = new Mock(); + this.lnsRemoteCall = new Mock(); this.subject = new ModuleConnectionHost(this.networkServerConfiguration, this.loRaModuleClientFactory.Object, this.loRaDeviceApiServiceBase, @@ -58,7 +57,7 @@ public void When_Constructor_Receives_Null_Parameters_Should_Throw() ex = Assert.Throws(() => new ModuleConnectionHost(networkServerConfiguration, this.loRaModuleClientFactory.Object, null, this.lnsRemoteCall.Object, NullLogger.Instance, TestMeter.Instance)); Assert.Equal("loRaDeviceAPIService", ex.ParamName); ex = Assert.Throws(() => new ModuleConnectionHost(networkServerConfiguration, this.loRaModuleClientFactory.Object, this.loRaDeviceApiServiceBase, null, NullLogger.Instance, TestMeter.Instance)); - Assert.Equal("lnsRemoteCall", ex.ParamName); + Assert.Equal("lnsRemoteCallHandler", ex.ParamName); } [Fact] @@ -231,7 +230,7 @@ public async Task InitModuleAsync_Fails_When_Fail_IoT_Hub_Communication() public async Task OnDirectMethodCall_Should_Invoke_ClearCache() { await this.subject.OnDirectMethodCalled(new MethodRequest(Constants.CloudToDeviceClearCache), null); - this.lnsRemoteCall.Verify(l => l.ClearCacheAsync(), Times.Once); + this.lnsRemoteCall.Verify(l => l.ExecuteAsync(new LnsRemoteCall(RemoteCallKind.ClearCache, null), CancellationToken.None), Times.Once); } [Fact] @@ -245,7 +244,7 @@ public async Task OnDirectMethodCall_Should_Invoke_DropConnection() await this.subject.OnDirectMethodCalled(methodRequest, null); // assert - this.lnsRemoteCall.Verify(l => l.CloseConnectionAsync(json, CancellationToken.None), Times.Once); + this.lnsRemoteCall.Verify(l => l.ExecuteAsync(new LnsRemoteCall(RemoteCallKind.CloseConnection, json), CancellationToken.None), Times.Once); } [Fact] @@ -259,7 +258,7 @@ public async Task OnDirectMethodCall_Should_Invoke_SendCloudToDeviceMessageAsync var result = await this.subject.OnDirectMethodCalled(methodRequest, null); // assert - this.lnsRemoteCall.Verify(l => l.SendCloudToDeviceMessageAsync(json, CancellationToken.None), Times.Once); + this.lnsRemoteCall.Verify(l => l.ExecuteAsync(new LnsRemoteCall(RemoteCallKind.CloudToDeviceMessage, json), CancellationToken.None), Times.Once); } [Fact] From 818c004c82776324177a9aa4d987138274c9cf4b Mon Sep 17 00:00:00 2001 From: Bastian Burger <22341213+bastbu@users.noreply.github.com> Date: Wed, 18 May 2022 00:04:22 +0200 Subject: [PATCH 06/22] Fix integration tests (#1701) --- Tests/Integration/RedisRemoteCallListenerTests.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Tests/Integration/RedisRemoteCallListenerTests.cs b/Tests/Integration/RedisRemoteCallListenerTests.cs index f21dbab671..629081d1ac 100644 --- a/Tests/Integration/RedisRemoteCallListenerTests.cs +++ b/Tests/Integration/RedisRemoteCallListenerTests.cs @@ -7,6 +7,7 @@ namespace LoRaWan.Tests.Integration using System.Text.Json; using System.Threading.Tasks; using LoRaWan.NetworkServer; + using LoRaWan.Tests.Common; using Moq; using StackExchange.Redis; using Xunit; @@ -36,7 +37,7 @@ public async Task Subscribe_Receives_Message() await PublishAsync(lnsName, remoteCall); // assert - function.Verify(a => a.Invoke(remoteCall), Times.Once); + await function.RetryVerifyAsync(a => a.Invoke(remoteCall), Times.Once); } [Fact] @@ -50,7 +51,7 @@ public async Task Subscribe_On_Different_Channel_Does_Not_Receive_Message() await PublishAsync("lns-2", new LnsRemoteCall(RemoteCallKind.CloudToDeviceMessage, null)); // assert - function.Verify(a => a.Invoke(It.IsAny()), Times.Never); + await function.RetryVerifyAsync(a => a.Invoke(It.IsAny()), Times.Never); } private async Task PublishAsync(string channel, LnsRemoteCall lnsRemoteCall) From c16fcf4dd45eccc5f8c2812af89b791f0855ec44 Mon Sep 17 00:00:00 2001 From: Bastian Burger <22341213+bastbu@users.noreply.github.com> Date: Wed, 18 May 2022 00:22:34 +0200 Subject: [PATCH 07/22] Redis listener hosted service (#1702) Co-authored-by: danigian <1955514+danigian@users.noreply.github.com> --- .../BasicsStationNetworkServerStartup.cs | 133 +++++++++--------- .../LoRaWan.NetworkServer/CloudControlHost.cs | 35 +++++ .../LnsRemoteCallListener.cs | 22 ++- .../RedisRemoteCallListenerTests.cs | 21 ++- .../NetworkServer/CloudControlHostTests.cs | 57 ++++++++ 5 files changed, 197 insertions(+), 71 deletions(-) create mode 100644 LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/CloudControlHost.cs create mode 100644 Tests/Unit/NetworkServer/CloudControlHostTests.cs diff --git a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/BasicsStationNetworkServerStartup.cs b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/BasicsStationNetworkServerStartup.cs index 0f0b9413a1..051c6759d1 100644 --- a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/BasicsStationNetworkServerStartup.cs +++ b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/BasicsStationNetworkServerStartup.cs @@ -31,6 +31,7 @@ namespace LoRaWan.NetworkServer.BasicsStation using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.ApplicationInsights; using Prometheus; + using StackExchange.Redis; internal sealed class BasicsStationNetworkServerStartup { @@ -51,67 +52,67 @@ public void ConfigureServices(IServiceCollection services) var appInsightsKey = Configuration.GetValue("APPINSIGHTS_INSTRUMENTATIONKEY"); var useApplicationInsights = !string.IsNullOrEmpty(appInsightsKey); _ = services.AddLogging(loggingBuilder => - { - _ = loggingBuilder.ClearProviders(); - var logLevel = int.TryParse(NetworkServerConfiguration.LogLevel, NumberStyles.Integer, CultureInfo.InvariantCulture, out var logLevelNum) - ? (LogLevel)logLevelNum is var level && Enum.IsDefined(typeof(LogLevel), level) ? level : throw new InvalidCastException() - : Enum.Parse(NetworkServerConfiguration.LogLevel, true); - - _ = loggingBuilder.SetMinimumLevel(logLevel); - _ = loggingBuilder.AddLoRaConsoleLogger(c => c.LogLevel = logLevel); - - if (NetworkServerConfiguration.LogToTcp) - { - _ = loggingBuilder.AddTcpLogger(new TcpLoggerConfiguration(logLevel, NetworkServerConfiguration.LogToTcpAddress, - NetworkServerConfiguration.LogToTcpPort, - NetworkServerConfiguration.GatewayID)); - } - if (NetworkServerConfiguration.LogToHub) - _ = loggingBuilder.AddIotHubLogger(c => c.LogLevel = logLevel); - - if (useApplicationInsights) - { - _ = loggingBuilder.AddApplicationInsights(appInsightsKey) - .AddFilter(string.Empty, logLevel); - _ = services.AddSingleton(_ => new TelemetryInitializer(NetworkServerConfiguration)); - } - }) - .AddMemoryCache() - .AddHttpClient() - .AddApiClient(NetworkServerConfiguration, ApiVersion.LatestVersion) - .AddSingleton(NetworkServerConfiguration) - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton(loraModuleFactory) - .AddSingleton() - .AddSingleton>() - .AddSingleton() - .AddSingleton() - .AddSingleton(new LoRaDeviceCacheOptions { MaxUnobservedLifetime = TimeSpan.FromDays(10), RefreshInterval = TimeSpan.FromDays(2), ValidationInterval = TimeSpan.FromMinutes(10) }) - .AddTransient() - .AddTransient() - .AddSingleton() - .AddSingleton(new RegistryMetricTagBag(NetworkServerConfiguration)) - .AddSingleton(_ => new Meter(MetricRegistry.Namespace, MetricRegistry.Version)) - .AddHostedService(sp => - new MetricExporterHostedService( - new CompositeMetricExporter(useApplicationInsights ? new ApplicationInsightsMetricExporter(sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService>()) : null, - new PrometheusMetricExporter(sp.GetRequiredService(), sp.GetRequiredService>())))); + { + _ = loggingBuilder.ClearProviders(); + var logLevel = int.TryParse(NetworkServerConfiguration.LogLevel, NumberStyles.Integer, CultureInfo.InvariantCulture, out var logLevelNum) + ? (LogLevel)logLevelNum is var level && Enum.IsDefined(typeof(LogLevel), level) ? level : throw new InvalidCastException() + : Enum.Parse(NetworkServerConfiguration.LogLevel, true); + + _ = loggingBuilder.SetMinimumLevel(logLevel); + _ = loggingBuilder.AddLoRaConsoleLogger(c => c.LogLevel = logLevel); + + if (NetworkServerConfiguration.LogToTcp) + { + _ = loggingBuilder.AddTcpLogger(new TcpLoggerConfiguration(logLevel, NetworkServerConfiguration.LogToTcpAddress, + NetworkServerConfiguration.LogToTcpPort, + NetworkServerConfiguration.GatewayID)); + } + if (NetworkServerConfiguration.LogToHub) + _ = loggingBuilder.AddIotHubLogger(c => c.LogLevel = logLevel); + + if (useApplicationInsights) + { + _ = loggingBuilder.AddApplicationInsights(appInsightsKey) + .AddFilter(string.Empty, logLevel); + _ = services.AddSingleton(_ => new TelemetryInitializer(NetworkServerConfiguration)); + } + }) + .AddMemoryCache() + .AddHttpClient() + .AddApiClient(NetworkServerConfiguration, ApiVersion.LatestVersion) + .AddSingleton(NetworkServerConfiguration) + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton(loraModuleFactory) + .AddSingleton() + .AddSingleton>() + .AddSingleton() + .AddSingleton() + .AddSingleton(new LoRaDeviceCacheOptions { MaxUnobservedLifetime = TimeSpan.FromDays(10), RefreshInterval = TimeSpan.FromDays(2), ValidationInterval = TimeSpan.FromMinutes(10) }) + .AddTransient() + .AddTransient() + .AddSingleton() + .AddSingleton(new RegistryMetricTagBag(NetworkServerConfiguration)) + .AddSingleton(_ => new Meter(MetricRegistry.Namespace, MetricRegistry.Version)) + .AddHostedService(sp => + new MetricExporterHostedService( + new CompositeMetricExporter(useApplicationInsights ? new ApplicationInsightsMetricExporter(sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>()) : null, + new PrometheusMetricExporter(sp.GetRequiredService(), sp.GetRequiredService>())))); if (useApplicationInsights) { @@ -125,10 +126,12 @@ public void ConfigureServices(IServiceCollection services) if (NetworkServerConfiguration.ClientCertificateMode is not ClientCertificateMode.NoCertificate) _ = services.AddSingleton(); - if (NetworkServerConfiguration.RunningAsIoTEdgeModule) - { - _ = services.AddSingleton(); - } + + _ = NetworkServerConfiguration.RunningAsIoTEdgeModule + ? services.AddSingleton() + : services.AddHostedService() + .AddSingleton() + .AddSingleton(_ => new RedisRemoteCallListener(ConnectionMultiplexer.Connect(NetworkServerConfiguration.RedisConnectionString))); } #pragma warning disable CA1822 // Mark members as static diff --git a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/CloudControlHost.cs b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/CloudControlHost.cs new file mode 100644 index 0000000000..2a5978ad1d --- /dev/null +++ b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/CloudControlHost.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#nullable enable + +namespace LoRaWan.NetworkServer +{ + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Extensions.Hosting; + + internal class CloudControlHost : IHostedService + { + private readonly ILnsRemoteCallListener lnsRemoteCallListener; + private readonly ILnsRemoteCallHandler lnsRemoteCallHandler; + private readonly string gatewayId; + + public CloudControlHost(ILnsRemoteCallListener lnsRemoteCallListener, + ILnsRemoteCallHandler lnsRemoteCallHandler, + NetworkServerConfiguration networkServerConfiguration) + { + this.lnsRemoteCallListener = lnsRemoteCallListener; + this.lnsRemoteCallHandler = lnsRemoteCallHandler; + this.gatewayId = networkServerConfiguration.GatewayID; + } + + public Task StartAsync(CancellationToken cancellationToken) => + this.lnsRemoteCallListener.SubscribeAsync(this.gatewayId, + remoteCall => this.lnsRemoteCallHandler.ExecuteAsync(remoteCall, cancellationToken), + cancellationToken); + + public Task StopAsync(CancellationToken cancellationToken) => + this.lnsRemoteCallListener.UnsubscribeAsync(this.gatewayId, cancellationToken); + } +} diff --git a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LnsRemoteCallListener.cs b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LnsRemoteCallListener.cs index 20a0e29535..084acb3bea 100644 --- a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LnsRemoteCallListener.cs +++ b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LnsRemoteCallListener.cs @@ -7,12 +7,15 @@ namespace LoRaWan.NetworkServer { using System; using System.Text.Json; + using System.Threading; using System.Threading.Tasks; using StackExchange.Redis; internal interface ILnsRemoteCallListener { - void Subscribe(string lns, Func function); + Task SubscribeAsync(string lns, Func function, CancellationToken cancellationToken); + + Task UnsubscribeAsync(string lns, CancellationToken cancellationToken); } internal sealed class RedisRemoteCallListener : ILnsRemoteCallListener @@ -24,10 +27,21 @@ public RedisRemoteCallListener(ConnectionMultiplexer redis) this.redis = redis; } - public void Subscribe(string lns, Func function) + // Cancellation token to be passed when/if a future update to SubscribeAsync is allowing to use it + public async Task SubscribeAsync(string lns, Func function, CancellationToken cancellationToken) + { + var channelMessage = await this.redis.GetSubscriber().SubscribeAsync(lns); + channelMessage.OnMessage(value => + { + var lnsRemoteCall = JsonSerializer.Deserialize(value.Message) ?? throw new InvalidOperationException("Deserialization produced an empty LnsRemoteCall."); + return function(lnsRemoteCall); + }); + } + + // Cancellation token to be passed when/if a future update to UnsubscribeAsync is allowing to use it + public async Task UnsubscribeAsync(string lns, CancellationToken cancellationToken) { - this.redis.GetSubscriber().Subscribe(lns).OnMessage(value => - function(JsonSerializer.Deserialize(value.Message) ?? throw new ArgumentException("Input LnsRemoteCall json was not parsed as valid one."))); + await this.redis.GetSubscriber().UnsubscribeAsync(lns); } } } diff --git a/Tests/Integration/RedisRemoteCallListenerTests.cs b/Tests/Integration/RedisRemoteCallListenerTests.cs index 629081d1ac..2a1fd8d90a 100644 --- a/Tests/Integration/RedisRemoteCallListenerTests.cs +++ b/Tests/Integration/RedisRemoteCallListenerTests.cs @@ -5,6 +5,7 @@ namespace LoRaWan.Tests.Integration { using System; using System.Text.Json; + using System.Threading; using System.Threading.Tasks; using LoRaWan.NetworkServer; using LoRaWan.Tests.Common; @@ -33,7 +34,7 @@ public async Task Subscribe_Receives_Message() var function = new Mock>(); // act - this.subject.Subscribe(lnsName, function.Object); + await this.subject.SubscribeAsync(lnsName, function.Object, CancellationToken.None); await PublishAsync(lnsName, remoteCall); // assert @@ -47,13 +48,29 @@ public async Task Subscribe_On_Different_Channel_Does_Not_Receive_Message() var function = new Mock>(); // act - this.subject.Subscribe("lns-1", function.Object); + await this.subject.SubscribeAsync("lns-1", function.Object, CancellationToken.None); await PublishAsync("lns-2", new LnsRemoteCall(RemoteCallKind.CloudToDeviceMessage, null)); // assert await function.RetryVerifyAsync(a => a.Invoke(It.IsAny()), Times.Never); } + [Fact] + public async Task UnsubscribeAsync_Unsubscribes_Successfully() + { + // arrange + var lns = "lns-1"; + var function = new Mock>(); + await this.subject.SubscribeAsync(lns, function.Object, CancellationToken.None); + + // act + await this.subject.UnsubscribeAsync(lns, CancellationToken.None); + await PublishAsync(lns, new LnsRemoteCall(RemoteCallKind.CloudToDeviceMessage, null)); + + // assert + function.Verify(a => a.Invoke(It.IsAny()), Times.Never); + } + private async Task PublishAsync(string channel, LnsRemoteCall lnsRemoteCall) { await this.redis.GetSubscriber().PublishAsync(channel, JsonSerializer.Serialize(lnsRemoteCall)); diff --git a/Tests/Unit/NetworkServer/CloudControlHostTests.cs b/Tests/Unit/NetworkServer/CloudControlHostTests.cs new file mode 100644 index 0000000000..5e88521184 --- /dev/null +++ b/Tests/Unit/NetworkServer/CloudControlHostTests.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#nullable enable + +namespace LoRaWan.Tests.Unit.NetworkServer +{ + using System; + using System.Threading; + using System.Threading.Tasks; + using LoRaWan.NetworkServer; + using Moq; + using Xunit; + + public sealed class CloudControlHostTests + { + private const string GatewayId = "lns-1"; + private readonly Mock lnsRemoteCallHandler; + private readonly Mock lnsRemoteCallListener; + private readonly CloudControlHost subject; + + public CloudControlHostTests() + { + this.lnsRemoteCallHandler = new Mock(); + this.lnsRemoteCallListener = new Mock(); + this.subject = new CloudControlHost(this.lnsRemoteCallListener.Object, this.lnsRemoteCallHandler.Object, new NetworkServerConfiguration { GatewayID = GatewayId }); + } + + [Fact] + public async Task ExecuteAsync_Subscribes_To_LnsRemoteCallHandler() + { + // arrange + Func actualHandler = _ => Task.CompletedTask; + this.lnsRemoteCallListener + .Setup(l => l.SubscribeAsync(GatewayId, It.IsAny>(), It.IsAny())) + .Callback((string _, Func handler, CancellationToken _) => actualHandler = handler); + + // act + await this.subject.StartAsync(CancellationToken.None); + + // assert + this.lnsRemoteCallListener.Verify(l => l.SubscribeAsync(GatewayId, It.IsAny>(), It.IsAny())); + await actualHandler.Invoke(new LnsRemoteCall(RemoteCallKind.CloseConnection, null)); + this.lnsRemoteCallHandler.Verify(l => l.ExecuteAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task StopAsync_Unsubscribes() + { + // act + await this.subject.StopAsync(CancellationToken.None); + + // assert + this.lnsRemoteCallListener.Verify(l => l.UnsubscribeAsync(GatewayId, It.IsAny()), Times.Once); + } + } +} From f3b6b85a7515d4a1d74629cb95534f0e3a4b3cb2 Mon Sep 17 00:00:00 2001 From: mkcomer <61518615+mkcomer@users.noreply.github.com> Date: Tue, 17 May 2022 15:54:28 -0700 Subject: [PATCH 08/22] OCW2022 Decouple LoRaWAN Starter Kit from IoTEdge - 1632 (#1700) --- .../ RedisChannelPublisher.cs | 29 +++++++++++ .../LoraKeysManagerFacade/FacadeStartup.cs | 1 + .../IChannelPublisher.cs | 16 ++++++ .../SendCloudToDeviceMessage.cs | 4 +- .../ModuleConnection/ModuleConnectionHost.cs | 2 + .../LnsRemoteCallListener.cs | 1 + .../LnsRemoteCall.cs | 6 +-- .../Integration/RedisChannelPublisherTests.cs | 51 +++++++++++++++++++ Tests/Integration/RedisFixture.cs | 1 + .../RedisRemoteCallListenerTests.cs | 1 + .../LnsRemoteCallTests.cs | 4 +- .../NetworkServer/CloudControlHostTests.cs | 1 + .../LnsRemoteCallHandlerTests.cs | 1 + .../NetworkServer/ModuleConnectionHostTest.cs | 1 + 14 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 LoRaEngine/LoraKeysManagerFacade/ RedisChannelPublisher.cs create mode 100644 LoRaEngine/LoraKeysManagerFacade/IChannelPublisher.cs rename LoRaEngine/modules/LoRaWanNetworkSrvModule/{LoRaWan.NetworkServer => LoraTools}/LnsRemoteCall.cs (64%) create mode 100644 Tests/Integration/RedisChannelPublisherTests.cs rename Tests/Unit/{NetworkServer => LoRaTools}/LnsRemoteCallTests.cs (90%) diff --git a/LoRaEngine/LoraKeysManagerFacade/ RedisChannelPublisher.cs b/LoRaEngine/LoraKeysManagerFacade/ RedisChannelPublisher.cs new file mode 100644 index 0000000000..a871d3f4fb --- /dev/null +++ b/LoRaEngine/LoraKeysManagerFacade/ RedisChannelPublisher.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace LoraKeysManagerFacade +{ + using StackExchange.Redis; + using System.Threading.Tasks; + using Microsoft.Extensions.Logging; + using LoRaTools; + using System.Text.Json; + + public class RedisChannelPublisher : IChannelPublisher + { + private readonly ConnectionMultiplexer redis; + private readonly ILogger logger; + + public RedisChannelPublisher(ConnectionMultiplexer redis, ILogger logger) + { + this.redis = redis; + this.logger = logger; + } + + public async Task PublishAsync(string channel, LnsRemoteCall lnsRemoteCall) + { + this.logger.LogDebug("Publishing message to channel '{Channel}'.", channel); + _ = await this.redis.GetSubscriber().PublishAsync(channel, JsonSerializer.Serialize(lnsRemoteCall)); + } + } +} diff --git a/LoRaEngine/LoraKeysManagerFacade/FacadeStartup.cs b/LoRaEngine/LoraKeysManagerFacade/FacadeStartup.cs index a20dd84a6f..e62444df08 100644 --- a/LoRaEngine/LoraKeysManagerFacade/FacadeStartup.cs +++ b/LoRaEngine/LoraKeysManagerFacade/FacadeStartup.cs @@ -59,6 +59,7 @@ public override void Configure(IFunctionsHostBuilder builder) sp.GetRequiredService(), sp.GetRequiredService>())) .AddSingleton() + .AddSingleton(sp => new RedisChannelPublisher(redis, sp.GetRequiredService>())) .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/LoRaEngine/LoraKeysManagerFacade/IChannelPublisher.cs b/LoRaEngine/LoraKeysManagerFacade/IChannelPublisher.cs new file mode 100644 index 0000000000..4bd58a05bc --- /dev/null +++ b/LoRaEngine/LoraKeysManagerFacade/IChannelPublisher.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace LoraKeysManagerFacade +{ + using System.Threading.Tasks; + using LoRaTools; + + /// + /// Interface for publisher interation. + /// + public interface IChannelPublisher + { + Task PublishAsync(string channel, LnsRemoteCall lnsRemoteCall); + } +} diff --git a/LoRaEngine/LoraKeysManagerFacade/SendCloudToDeviceMessage/SendCloudToDeviceMessage.cs b/LoRaEngine/LoraKeysManagerFacade/SendCloudToDeviceMessage/SendCloudToDeviceMessage.cs index 2bad11c62a..85907b2ebf 100644 --- a/LoRaEngine/LoraKeysManagerFacade/SendCloudToDeviceMessage/SendCloudToDeviceMessage.cs +++ b/LoRaEngine/LoraKeysManagerFacade/SendCloudToDeviceMessage/SendCloudToDeviceMessage.cs @@ -162,6 +162,7 @@ private async Task SendMessageViaCloudToDeviceMessageAsync(DevEui using var message = new Message(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(c2dMessage))); message.MessageId = string.IsNullOrEmpty(c2dMessage.MessageId) ? Guid.NewGuid().ToString() : c2dMessage.MessageId; + // class a devices only listen for 1-2 seconds, so we send to a queue on the device - we don't care about this for redis try { await this.serviceClient.SendAsync(devEUI.ToString(), message); @@ -197,7 +198,8 @@ private async Task SendMessageViaDirectMethodAsync( { var method = new CloudToDeviceMethod(LoraKeysManagerFacadeConstants.CloudToDeviceMessageMethodName, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1)); _ = method.SetPayloadJson(JsonConvert.SerializeObject(c2dMessage)); - + // class c devices are always listening - we want to publish message to redis queue here + // call IChannelPublisher.PublishAsync var res = await this.serviceClient.InvokeDeviceMethodAsync(preferredGatewayID, LoraKeysManagerFacadeConstants.NetworkServerModuleId, method); if (HttpUtilities.IsSuccessStatusCode(res.Status)) { diff --git a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/ModuleConnection/ModuleConnectionHost.cs b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/ModuleConnection/ModuleConnectionHost.cs index 3f1257f37b..2659bccb38 100644 --- a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/ModuleConnection/ModuleConnectionHost.cs +++ b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/ModuleConnection/ModuleConnectionHost.cs @@ -4,6 +4,7 @@ namespace LoRaWan.NetworkServer.BasicsStation.ModuleConnection { using LoRaTools.Utils; + using LoRaTools; using LoRaWan.NetworkServer; using Microsoft.Azure.Devices.Client; using Microsoft.Azure.Devices.Client.Exceptions; @@ -82,6 +83,7 @@ internal async Task InitModuleAsync(CancellationToken cancellationToken) await this.loRaModuleClient.SetMethodDefaultHandlerAsync(OnDirectMethodCalled, null); } + // handlers on device -- to be replaced with redis subscriber internal async Task OnDirectMethodCalled(MethodRequest methodRequest, object userContext) { if (methodRequest == null) throw new ArgumentNullException(nameof(methodRequest)); diff --git a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LnsRemoteCallListener.cs b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LnsRemoteCallListener.cs index 084acb3bea..60265eff13 100644 --- a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LnsRemoteCallListener.cs +++ b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LnsRemoteCallListener.cs @@ -10,6 +10,7 @@ namespace LoRaWan.NetworkServer using System.Threading; using System.Threading.Tasks; using StackExchange.Redis; + using LoRaTools; internal interface ILnsRemoteCallListener { diff --git a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LnsRemoteCall.cs b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoraTools/LnsRemoteCall.cs similarity index 64% rename from LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LnsRemoteCall.cs rename to LoRaEngine/modules/LoRaWanNetworkSrvModule/LoraTools/LnsRemoteCall.cs index 49dd843800..bec17ab930 100644 --- a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LnsRemoteCall.cs +++ b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoraTools/LnsRemoteCall.cs @@ -3,11 +3,11 @@ #nullable enable -namespace LoRaWan.NetworkServer +namespace LoRaTools { - internal sealed record LnsRemoteCall(RemoteCallKind Kind, string? JsonData); + public sealed record LnsRemoteCall(RemoteCallKind Kind, string? JsonData); - internal enum RemoteCallKind + public enum RemoteCallKind { CloudToDeviceMessage, ClearCache, diff --git a/Tests/Integration/RedisChannelPublisherTests.cs b/Tests/Integration/RedisChannelPublisherTests.cs new file mode 100644 index 0000000000..c4ea45ca0c --- /dev/null +++ b/Tests/Integration/RedisChannelPublisherTests.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace LoRaWan.Tests.Integration +{ + using System; + using System.Threading.Tasks; + using LoRaWan.Tests.Common; + using Xunit; + using LoraKeysManagerFacade; + using Xunit.Abstractions; + using Microsoft.Extensions.Logging.Abstractions; + using StackExchange.Redis; + using Moq; + using LoRaTools; + using System.Text.Json; + + [Collection(RedisFixture.CollectionName)] + public class RedisChannelPublisherTests : IClassFixture + { + private readonly IChannelPublisher channelPublisher; + private readonly ITestOutputHelper testOutputHelper; + private readonly ConnectionMultiplexer redis; + + public RedisChannelPublisherTests(RedisFixture redis, ITestOutputHelper testOutputHelper) + { + if (redis is null) throw new ArgumentNullException(nameof(redis)); + this.channelPublisher = new RedisChannelPublisher(redis.Redis, NullLogger.Instance); + this.testOutputHelper = testOutputHelper; + this.redis = redis.Redis; + } + + [Fact] + public async Task Publish_Aysnc() + { + // arrange + var message = new LnsRemoteCall(RemoteCallKind.CloseConnection, "test message"); + var serializedMessage = JsonSerializer.Serialize(message); + var channel = "channel1"; + var assert = new Mock>(); + this.testOutputHelper.WriteLine("Publishing message..."); + (await this.redis.GetSubscriber().SubscribeAsync(channel)).OnMessage(assert.Object); + + // act + await this.channelPublisher.PublishAsync(channel, message); + + // assert + await assert.RetryVerifyAsync(a => a.Invoke(It.Is(actual => actual.Message == serializedMessage)), Times.Once); + } + } +} diff --git a/Tests/Integration/RedisFixture.cs b/Tests/Integration/RedisFixture.cs index f71cfb619a..10d8f7a3bd 100644 --- a/Tests/Integration/RedisFixture.cs +++ b/Tests/Integration/RedisFixture.cs @@ -134,6 +134,7 @@ public async Task InitializeAsync() throw new InvalidOperationException($"Failed to connect to redis at '{redisConnectionString}'. If running locally with docker: run 'docker run -d -p 6379:6379 redis'. If running in Azure DevOps: run redis in docker.", ex); } } + public async Task DisposeAsync() { diff --git a/Tests/Integration/RedisRemoteCallListenerTests.cs b/Tests/Integration/RedisRemoteCallListenerTests.cs index 2a1fd8d90a..107489b27e 100644 --- a/Tests/Integration/RedisRemoteCallListenerTests.cs +++ b/Tests/Integration/RedisRemoteCallListenerTests.cs @@ -7,6 +7,7 @@ namespace LoRaWan.Tests.Integration using System.Text.Json; using System.Threading; using System.Threading.Tasks; + using LoRaTools; using LoRaWan.NetworkServer; using LoRaWan.Tests.Common; using Moq; diff --git a/Tests/Unit/NetworkServer/LnsRemoteCallTests.cs b/Tests/Unit/LoRaTools/LnsRemoteCallTests.cs similarity index 90% rename from Tests/Unit/NetworkServer/LnsRemoteCallTests.cs rename to Tests/Unit/LoRaTools/LnsRemoteCallTests.cs index 506e2d7abe..a988b6c45e 100644 --- a/Tests/Unit/NetworkServer/LnsRemoteCallTests.cs +++ b/Tests/Unit/LoRaTools/LnsRemoteCallTests.cs @@ -3,10 +3,10 @@ #nullable enable -namespace LoRaWan.Tests.Unit.NetworkServer +namespace LoRaWan.Tests.Unit.LoRaTools { using System.Text.Json; - using LoRaWan.NetworkServer; + using global::LoRaTools; using Xunit; public sealed class LnsRemoteCallTests diff --git a/Tests/Unit/NetworkServer/CloudControlHostTests.cs b/Tests/Unit/NetworkServer/CloudControlHostTests.cs index 5e88521184..0ef185c8ef 100644 --- a/Tests/Unit/NetworkServer/CloudControlHostTests.cs +++ b/Tests/Unit/NetworkServer/CloudControlHostTests.cs @@ -8,6 +8,7 @@ namespace LoRaWan.Tests.Unit.NetworkServer using System; using System.Threading; using System.Threading.Tasks; + using global::LoRaTools; using LoRaWan.NetworkServer; using Moq; using Xunit; diff --git a/Tests/Unit/NetworkServer/LnsRemoteCallHandlerTests.cs b/Tests/Unit/NetworkServer/LnsRemoteCallHandlerTests.cs index c9a05f23c4..95485281c7 100644 --- a/Tests/Unit/NetworkServer/LnsRemoteCallHandlerTests.cs +++ b/Tests/Unit/NetworkServer/LnsRemoteCallHandlerTests.cs @@ -9,6 +9,7 @@ namespace LoRaWan.Tests.Unit.NetworkServer using System.Threading; using System.Threading.Tasks; using Bogus; + using global::LoRaTools; using LoRaWan.NetworkServer; using LoRaWan.Tests.Common; using Microsoft.Extensions.Logging; diff --git a/Tests/Unit/NetworkServer/ModuleConnectionHostTest.cs b/Tests/Unit/NetworkServer/ModuleConnectionHostTest.cs index bf16478f8e..b30f93c3b5 100644 --- a/Tests/Unit/NetworkServer/ModuleConnectionHostTest.cs +++ b/Tests/Unit/NetworkServer/ModuleConnectionHostTest.cs @@ -4,6 +4,7 @@ namespace LoRaWan.Tests.Unit.NetworkServer { using Bogus; + using global::LoRaTools; using LoRaWan.NetworkServer; using LoRaWan.NetworkServer.BasicsStation.ModuleConnection; using LoRaWan.Tests.Common; From a5d0828293cd3bf42cba721c34b8eef46b9a6d73 Mon Sep 17 00:00:00 2001 From: Daniele Antonio Maggio <1955514+danigian@users.noreply.github.com> Date: Tue, 17 May 2022 15:57:04 -0700 Subject: [PATCH 09/22] Providing alternatives to IOTEDGE_* environment variables (#1704) * Providing alternatives to IOTEDGE_* environment variables * Falling back without if else --- .../NetworkServerConfiguration.cs | 10 ++++-- .../BasicsStationNetworkServerStartupTests.cs | 35 ++++++++++++++----- Tests/Unit/NetworkServer/ConfigurationTest.cs | 21 +++++++++-- .../NetworkServer/ModuleConnectionHostTest.cs | 2 +- 4 files changed, 54 insertions(+), 14 deletions(-) diff --git a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/NetworkServerConfiguration.cs b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/NetworkServerConfiguration.cs index 2999c6ba73..8b632167c1 100644 --- a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/NetworkServerConfiguration.cs +++ b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/NetworkServerConfiguration.cs @@ -154,14 +154,20 @@ public static NetworkServerConfiguration CreateFromEnvironmentVariables() var envVars = new CaseInsensitiveEnvironmentVariables(Environment.GetEnvironmentVariables()); config.ProcessingDelayInMilliseconds = envVars.GetEnvVar("PROCESSING_DELAY_IN_MS", config.ProcessingDelayInMilliseconds); config.RunningAsIoTEdgeModule = !envVars.GetEnvVar("CLOUD_DEPLOYMENT", false); - config.IoTHubHostName = envVars.GetEnvVar("IOTEDGE_IOTHUBHOSTNAME", string.Empty); + + var iotHubHostName = envVars.GetEnvVar("IOTEDGE_IOTHUBHOSTNAME", envVars.GetEnvVar("IOTHUBHOSTNAME", string.Empty)); + config.IoTHubHostName = !string.IsNullOrEmpty(iotHubHostName) ? iotHubHostName : throw new InvalidOperationException("Either 'IOTEDGE_IOTHUBHOSTNAME' or 'IOTHUBHOSTNAME' environment variable should be populated"); + config.GatewayHostName = envVars.GetEnvVar("IOTEDGE_GATEWAYHOSTNAME", string.Empty); config.EnableGateway = envVars.GetEnvVar("ENABLE_GATEWAY", true); if (!config.RunningAsIoTEdgeModule && config.EnableGateway) { throw new NotSupportedException("ENABLE_GATEWAY cannot be true if RunningAsIoTEdgeModule is false."); } - config.GatewayID = envVars.GetEnvVar("IOTEDGE_DEVICEID", string.Empty); + + var gatewayId = envVars.GetEnvVar("IOTEDGE_DEVICEID", envVars.GetEnvVar("HOSTNAME", string.Empty)); + config.GatewayID = !string.IsNullOrEmpty(gatewayId) ? gatewayId : throw new InvalidOperationException("Either 'IOTEDGE_DEVICEID' or 'HOSTNAME' environment variable should be populated"); + config.HttpsProxy = envVars.GetEnvVar("HTTPS_PROXY", string.Empty); config.Rx2DataRate = envVars.GetEnvVar("RX2_DATR", -1) is var datrNum && (DataRateIndex)datrNum is var datr && Enum.IsDefined(datr) ? datr : null; config.Rx2Frequency = envVars.GetEnvVar("RX2_FREQ") is { } someFreq ? Hertz.Mega(someFreq) : null; diff --git a/Tests/Unit/NetworkServer/BasicsStation/BasicsStationNetworkServerStartupTests.cs b/Tests/Unit/NetworkServer/BasicsStation/BasicsStationNetworkServerStartupTests.cs index eeddcad3fa..e940590c78 100644 --- a/Tests/Unit/NetworkServer/BasicsStation/BasicsStationNetworkServerStartupTests.cs +++ b/Tests/Unit/NetworkServer/BasicsStation/BasicsStationNetworkServerStartupTests.cs @@ -18,16 +18,33 @@ public void All_Dependencies_Are_Registered_Correctly() // arrange var services = new ServiceCollection(); var config = new ConfigurationBuilder().Build(); + var envVariables = new[] + { + ("HOSTNAME", "test"), + ("IOTHUBHOSTNAME", "test") + }; - // act + assert - var startup = new BasicsStationNetworkServerStartup(config); - startup.ConfigureServices(services); + try + { + foreach (var (key, value) in envVariables) + Environment.SetEnvironmentVariable(key, value); + + // act + assert + var startup = new BasicsStationNetworkServerStartup(config); + startup.ConfigureServices(services); - services.BuildServiceProvider(new ServiceProviderOptions + services.BuildServiceProvider(new ServiceProviderOptions + { + ValidateOnBuild = true, + ValidateScopes = true + }); + + } + finally { - ValidateOnBuild = true, - ValidateScopes = true - }); + foreach (var (key, _) in envVariables) + Environment.SetEnvironmentVariable(key, string.Empty); + } } [Theory] @@ -39,7 +56,9 @@ public void ModuleConnectionHostIsInjectedOrNot(bool cloud_deployment, bool enab { ("CLOUD_DEPLOYMENT", cloud_deployment.ToString()), ("ENABLE_GATEWAY", enable_gateway.ToString()), - ("REDIS_CONNECTION_STRING", "someString") + ("REDIS_CONNECTION_STRING", "someString"), + ("HOSTNAME", "test"), + ("IOTHUBHOSTNAME", "test") }; try diff --git a/Tests/Unit/NetworkServer/ConfigurationTest.cs b/Tests/Unit/NetworkServer/ConfigurationTest.cs index 35e2bb0618..e52434bec2 100644 --- a/Tests/Unit/NetworkServer/ConfigurationTest.cs +++ b/Tests/Unit/NetworkServer/ConfigurationTest.cs @@ -14,7 +14,13 @@ public class ConfigurationTest [MemberData(nameof(AllowedDevAddressesInput))] public void Should_Setup_Allowed_Dev_Addresses_Correctly(string inputAllowedDevAddrValues, DevAddr[] expectedAllowedDevAddrValues) { - var envVariables = new[] { ("AllowedDevAddresses", inputAllowedDevAddrValues), ("FACADE_SERVER_URL", "https://aka.ms") }; + var envVariables = new[] + { + ("AllowedDevAddresses", inputAllowedDevAddrValues), + ("FACADE_SERVER_URL", "https://aka.ms"), + ("HOSTNAME", "test"), + ("IOTHUBHOSTNAME", "test") + }; try { @@ -48,6 +54,9 @@ public void Should_Throw_On_Invalid_Cloud_Configuration_When_Redis_Connection_St var value = "someValue"; var lnsConfigurationCreation = () => NetworkServerConfiguration.CreateFromEnvironmentVariables(); + Environment.SetEnvironmentVariable("HOSTNAME", "test"); + Environment.SetEnvironmentVariable("IOTHUBHOSTNAME", "test"); + if (isCloudDeployment) { Environment.SetEnvironmentVariable(cloudDeploymentKey, true.ToString()); @@ -86,7 +95,9 @@ public void EnableGatewayTrue_IoTModuleFalse_IsNotSupported(bool cloud_deploymen { ("CLOUD_DEPLOYMENT", cloud_deployment.ToString()), ("ENABLE_GATEWAY", enable_gateway.ToString()), - ("REDIS_CONNECTION_STRING", "someString") + ("REDIS_CONNECTION_STRING", "someString"), + ("HOSTNAME", "test"), + ("IOTHUBHOSTNAME", "test") }; try @@ -117,7 +128,11 @@ public void EnableGatewayTrue_IoTModuleFalse_IsNotSupported(bool cloud_deploymen [InlineData("x")] public void ProcessingDelayIsConfigurable(string processing_delay) { - var envVariables = new[] { ("PROCESSING_DELAY_IN_MS", processing_delay) }; + var envVariables = new[] + { + ("PROCESSING_DELAY_IN_MS", processing_delay), + ("HOSTNAME", "test") + }; try { diff --git a/Tests/Unit/NetworkServer/ModuleConnectionHostTest.cs b/Tests/Unit/NetworkServer/ModuleConnectionHostTest.cs index b30f93c3b5..7e2c15fd7a 100644 --- a/Tests/Unit/NetworkServer/ModuleConnectionHostTest.cs +++ b/Tests/Unit/NetworkServer/ModuleConnectionHostTest.cs @@ -34,7 +34,7 @@ public sealed class ModuleConnectionHostTest : IAsyncDisposable public ModuleConnectionHostTest() { - this.networkServerConfiguration = NetworkServerConfiguration.CreateFromEnvironmentVariables(); + this.networkServerConfiguration = new NetworkServerConfiguration(); this.loRaModuleClient.Setup(x => x.DisposeAsync()); this.loRaModuleClientFactory.Setup(x => x.CreateAsync()).ReturnsAsync(loRaModuleClient.Object); this.lnsRemoteCall = new Mock(); From 1b352c994c9947088a6c4fcb51f70cefa40d41be Mon Sep 17 00:00:00 2001 From: Bastian Burger <22341213+bastbu@users.noreply.github.com> Date: Wed, 18 May 2022 17:34:38 +0200 Subject: [PATCH 10/22] Track unhandled exceptions in Redis listener (#1706) --- .../BasicsStationNetworkServerStartup.cs | 5 +- .../LnsRemoteCallListener.cs | 20 ++++++-- .../RedisRemoteCallListenerTests.cs | 46 ++++++++++++++++++- 3 files changed, 66 insertions(+), 5 deletions(-) diff --git a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/BasicsStationNetworkServerStartup.cs b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/BasicsStationNetworkServerStartup.cs index 051c6759d1..776f3c11aa 100644 --- a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/BasicsStationNetworkServerStartup.cs +++ b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/BasicsStationNetworkServerStartup.cs @@ -131,7 +131,10 @@ public void ConfigureServices(IServiceCollection services) ? services.AddSingleton() : services.AddHostedService() .AddSingleton() - .AddSingleton(_ => new RedisRemoteCallListener(ConnectionMultiplexer.Connect(NetworkServerConfiguration.RedisConnectionString))); + .AddSingleton(sp => + new RedisRemoteCallListener(ConnectionMultiplexer.Connect(NetworkServerConfiguration.RedisConnectionString), + sp.GetRequiredService>(), + sp.GetRequiredService())); } #pragma warning disable CA1822 // Mark members as static diff --git a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LnsRemoteCallListener.cs b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LnsRemoteCallListener.cs index 60265eff13..68fac61781 100644 --- a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LnsRemoteCallListener.cs +++ b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LnsRemoteCallListener.cs @@ -11,6 +11,8 @@ namespace LoRaWan.NetworkServer using System.Threading.Tasks; using StackExchange.Redis; using LoRaTools; + using Microsoft.Extensions.Logging; + using System.Diagnostics.Metrics; internal interface ILnsRemoteCallListener { @@ -22,10 +24,14 @@ internal interface ILnsRemoteCallListener internal sealed class RedisRemoteCallListener : ILnsRemoteCallListener { private readonly ConnectionMultiplexer redis; + private readonly ILogger logger; + private readonly Counter unhandledExceptionCount; - public RedisRemoteCallListener(ConnectionMultiplexer redis) + public RedisRemoteCallListener(ConnectionMultiplexer redis, ILogger logger, Meter meter) { this.redis = redis; + this.logger = logger; + this.unhandledExceptionCount = meter.CreateCounter(MetricRegistry.UnhandledExceptions); } // Cancellation token to be passed when/if a future update to SubscribeAsync is allowing to use it @@ -34,8 +40,16 @@ public async Task SubscribeAsync(string lns, Func function, var channelMessage = await this.redis.GetSubscriber().SubscribeAsync(lns); channelMessage.OnMessage(value => { - var lnsRemoteCall = JsonSerializer.Deserialize(value.Message) ?? throw new InvalidOperationException("Deserialization produced an empty LnsRemoteCall."); - return function(lnsRemoteCall); + try + { + var lnsRemoteCall = JsonSerializer.Deserialize(value.Message) ?? throw new InvalidOperationException("Deserialization produced an empty LnsRemoteCall."); + return function(lnsRemoteCall); + } + catch (Exception ex) when (ExceptionFilterUtility.False(() => this.logger.LogError(ex, $"An exception occurred when reacting to a Redis message: '{ex}'."), + () => this.unhandledExceptionCount.Add(1))) + { + throw; + } }); } diff --git a/Tests/Integration/RedisRemoteCallListenerTests.cs b/Tests/Integration/RedisRemoteCallListenerTests.cs index 107489b27e..da78fa18e9 100644 --- a/Tests/Integration/RedisRemoteCallListenerTests.cs +++ b/Tests/Integration/RedisRemoteCallListenerTests.cs @@ -4,26 +4,31 @@ namespace LoRaWan.Tests.Integration { using System; + using System.Collections.Generic; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using LoRaTools; using LoRaWan.NetworkServer; using LoRaWan.Tests.Common; + using Microsoft.Extensions.Logging; using Moq; using StackExchange.Redis; using Xunit; + using Xunit.Sdk; [Collection(RedisFixture.CollectionName)] public sealed class RedisRemoteCallListenerTests : IClassFixture { private readonly ConnectionMultiplexer redis; + private readonly Mock> logger; private readonly RedisRemoteCallListener subject; public RedisRemoteCallListenerTests(RedisFixture redisFixture) { this.redis = redisFixture.Redis; - this.subject = new RedisRemoteCallListener(this.redis); + this.logger = new Mock>(); + this.subject = new RedisRemoteCallListener(this.redis, this.logger.Object, TestMeter.Instance); } [Fact] @@ -72,9 +77,48 @@ public async Task UnsubscribeAsync_Unsubscribes_Successfully() function.Verify(a => a.Invoke(It.IsAny()), Times.Never); } + [Fact] + public async Task SubscribeAsync_Exceptions_Are_Tracked() + { + // arrange + var lns = "lns-1"; + var function = new Mock>(); + + // act + await this.subject.SubscribeAsync(lns, function.Object, CancellationToken.None); + await this.redis.GetSubscriber().PublishAsync(lns, string.Empty); + + // assert + var invocation = await RetryAssertSingleAsync(this.logger.GetLogInvocations()); + _ = Assert.IsType(invocation.Exception); + } + private async Task PublishAsync(string channel, LnsRemoteCall lnsRemoteCall) { await this.redis.GetSubscriber().PublishAsync(channel, JsonSerializer.Serialize(lnsRemoteCall)); } + + private static async Task RetryAssertSingleAsync(IEnumerable sequence, + int numberOfRetries = 5, + TimeSpan? delay = null) + { + var retryDelay = delay ?? TimeSpan.FromMilliseconds(50); + for (var i = 0; i < numberOfRetries + 1; ++i) + { + try + { + var result = Assert.Single(sequence); + return result; + } + catch (SingleException) when (i < numberOfRetries) + { + // assertion does not yet pass, retry once more. + await Task.Delay(retryDelay); + continue; + } + } + + throw new InvalidOperationException("asdfasdf"); + } } } From f633835d7ee1490c5c0d58c8fc6afb510b0931a0 Mon Sep 17 00:00:00 2001 From: Daniele Antonio Maggio <1955514+danigian@users.noreply.github.com> Date: Wed, 18 May 2022 10:45:53 -0700 Subject: [PATCH 11/22] Handling alternative paths for direct method invocations from Azure Function (#1707) --- .../LoraKeysManagerFacade/EdgeDeviceGetter.cs | 4 +- .../LoraKeysManagerFacade/FacadeStartup.cs | 2 +- .../DeduplicationExecutionItem.cs | 32 ++++- .../IEdgeDeviceGetter.cs | 13 ++ .../SendCloudToDeviceMessage.cs | 69 ++++++---- .../DeduplicationWithRedisIntegrationTests.cs | 43 +++++-- Tests/Unit/LoRaTools/ApiVersionTest.cs | 2 +- .../FunctionBundlerTest.cs | 12 +- .../MessageDeduplicationTests.cs | 11 +- .../SendCloudToDeviceMessageTest.cs | 118 +++++++++++++----- 10 files changed, 231 insertions(+), 75 deletions(-) create mode 100644 LoRaEngine/LoraKeysManagerFacade/IEdgeDeviceGetter.cs diff --git a/LoRaEngine/LoraKeysManagerFacade/EdgeDeviceGetter.cs b/LoRaEngine/LoraKeysManagerFacade/EdgeDeviceGetter.cs index 2cbdb4ce6b..7d9804e249 100644 --- a/LoRaEngine/LoraKeysManagerFacade/EdgeDeviceGetter.cs +++ b/LoRaEngine/LoraKeysManagerFacade/EdgeDeviceGetter.cs @@ -11,7 +11,7 @@ namespace LoraKeysManagerFacade using Microsoft.Azure.Devices.Shared; using Microsoft.Extensions.Logging; - internal class EdgeDeviceGetter + public class EdgeDeviceGetter : IEdgeDeviceGetter { private readonly IDeviceRegistryManager registryManager; private readonly ILoRaDeviceCacheStore cacheStore; @@ -41,7 +41,7 @@ private async Task> GetEdgeDevicesAsync(CancellationToken cancellatio return twins; } - internal async Task IsEdgeDeviceAsync(string lnsId, CancellationToken cancellationToken) + public async Task IsEdgeDeviceAsync(string lnsId, CancellationToken cancellationToken) { const string keyLock = $"{nameof(EdgeDeviceGetter)}-lock"; const string owner = nameof(EdgeDeviceGetter); diff --git a/LoRaEngine/LoraKeysManagerFacade/FacadeStartup.cs b/LoRaEngine/LoraKeysManagerFacade/FacadeStartup.cs index e62444df08..d59579ba31 100644 --- a/LoRaEngine/LoraKeysManagerFacade/FacadeStartup.cs +++ b/LoRaEngine/LoraKeysManagerFacade/FacadeStartup.cs @@ -61,7 +61,7 @@ public override void Configure(IFunctionsHostBuilder builder) .AddSingleton() .AddSingleton(sp => new RedisChannelPublisher(redis, sp.GetRequiredService>())) .AddSingleton() - .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/LoRaEngine/LoraKeysManagerFacade/FunctionsBundler/DeduplicationExecutionItem.cs b/LoRaEngine/LoraKeysManagerFacade/FunctionsBundler/DeduplicationExecutionItem.cs index 3ebd8e9484..498aa5fdbb 100644 --- a/LoRaEngine/LoraKeysManagerFacade/FunctionsBundler/DeduplicationExecutionItem.cs +++ b/LoRaEngine/LoraKeysManagerFacade/FunctionsBundler/DeduplicationExecutionItem.cs @@ -4,7 +4,9 @@ namespace LoraKeysManagerFacade.FunctionBundler { using System; + using System.Threading; using System.Threading.Tasks; + using LoRaTools; using LoRaTools.CommonAPI; using LoRaWan; using Microsoft.ApplicationInsights; @@ -21,16 +23,23 @@ public class DeduplicationExecutionItem : IFunctionBundlerExecutionItem private readonly ILoRaDeviceCacheStore cacheStore; private readonly IServiceClient serviceClient; + private readonly IEdgeDeviceGetter edgeDeviceGetter; + private readonly IChannelPublisher channelPublisher; private readonly Microsoft.ApplicationInsights.Metric connectionOwnershipChangedMetric; + private static readonly TimeSpan DuplicateMessageTimeout = TimeSpan.FromSeconds(30); + public DeduplicationExecutionItem( ILoRaDeviceCacheStore cacheStore, IServiceClient serviceClient, + IEdgeDeviceGetter edgeDeviceGetter, + IChannelPublisher channelPublisher, TelemetryConfiguration telemetryConfiguration) { this.cacheStore = cacheStore; this.serviceClient = serviceClient; - + this.edgeDeviceGetter = edgeDeviceGetter; + this.channelPublisher = channelPublisher; var telemetryClient = new TelemetryClient(telemetryConfiguration); var metricIdentifier = new MetricIdentifier(LoraKeysManagerFacadeConstants.MetricNamespace, ConnectionOwnershipChangeMetricName); this.connectionOwnershipChangedMetric = telemetryClient.GetMetric(metricIdentifier); @@ -59,6 +68,7 @@ public Task OnAbortExecutionAsync(IPipelineExecutionContext context) internal async Task GetDuplicateMessageResultAsync(DevEui devEUI, string gatewayId, uint clientFCntUp, uint clientFCntDown, ILogger logger = null) { + using var cts = new CancellationTokenSource(DuplicateMessageTimeout); var isDuplicate = true; var processedDevice = gatewayId; @@ -105,16 +115,26 @@ internal async Task GetDuplicateMessageResultAsync(DevEui de }; var method = new CloudToDeviceMethod(LoraKeysManagerFacadeConstants.CloudToDeviceCloseConnection, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1)); - _ = method.SetPayloadJson(JsonConvert.SerializeObject(loraC2DMessage)); + var jsonContents = JsonConvert.SerializeObject(loraC2DMessage); + _ = method.SetPayloadJson(jsonContents); try { - var res = await this.serviceClient.InvokeDeviceMethodAsync(previousGateway, LoraKeysManagerFacadeConstants.NetworkServerModuleId, method); - logger?.LogDebug("Connection owner changed and direct method was called on previous gateway '{PreviousConnectionOwner}' to close connection; result is '{Status}'", previousGateway, res?.Status); + if (await this.edgeDeviceGetter.IsEdgeDeviceAsync(previousGateway, cts.Token)) + { + var res = await this.serviceClient.InvokeDeviceMethodAsync(previousGateway, LoraKeysManagerFacadeConstants.NetworkServerModuleId, method); + logger?.LogDebug("Connection owner changed and direct method was called on previous gateway '{PreviousConnectionOwner}' to close connection; result is '{Status}'", previousGateway, res?.Status); - if (!HttpUtilities.IsSuccessStatusCode(res.Status)) + if (!HttpUtilities.IsSuccessStatusCode(res.Status)) + { + logger?.LogError("Failed to invoke direct method on LNS '{PreviousConnectionOwner}' to close the connection for device '{DevEUI}'; status '{Status}'", previousGateway, devEUI, res?.Status); + } + + } + else { - logger?.LogError("Failed to invoke direct method on LNS '{PreviousConnectionOwner}' to close the connection for device '{DevEUI}'; status '{Status}'", previousGateway, devEUI, res?.Status); + await this.channelPublisher.PublishAsync(previousGateway, new LnsRemoteCall(RemoteCallKind.CloseConnection, jsonContents)); + logger?.LogDebug("Connection owner changed and message was published to previous gateway '{PreviousConnectionOwner}' to close connection", previousGateway); } } catch (IotHubException ex) diff --git a/LoRaEngine/LoraKeysManagerFacade/IEdgeDeviceGetter.cs b/LoRaEngine/LoraKeysManagerFacade/IEdgeDeviceGetter.cs new file mode 100644 index 0000000000..5607c48ecc --- /dev/null +++ b/LoRaEngine/LoraKeysManagerFacade/IEdgeDeviceGetter.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace LoraKeysManagerFacade +{ + using System.Threading; + using System.Threading.Tasks; + + public interface IEdgeDeviceGetter + { + Task IsEdgeDeviceAsync(string lnsId, CancellationToken cancellationToken); + } +} diff --git a/LoRaEngine/LoraKeysManagerFacade/SendCloudToDeviceMessage/SendCloudToDeviceMessage.cs b/LoRaEngine/LoraKeysManagerFacade/SendCloudToDeviceMessage/SendCloudToDeviceMessage.cs index 85907b2ebf..5ecbca748d 100644 --- a/LoRaEngine/LoraKeysManagerFacade/SendCloudToDeviceMessage/SendCloudToDeviceMessage.cs +++ b/LoRaEngine/LoraKeysManagerFacade/SendCloudToDeviceMessage/SendCloudToDeviceMessage.cs @@ -8,6 +8,7 @@ namespace LoraKeysManagerFacade using System.Linq; using System.Net; using System.Text; + using System.Threading; using System.Threading.Tasks; using LoRaTools; using LoRaTools.CommonAPI; @@ -33,20 +34,30 @@ public class SendCloudToDeviceMessage private readonly ILoRaDeviceCacheStore cacheStore; private readonly IDeviceRegistryManager registryManager; private readonly IServiceClient serviceClient; + private readonly IEdgeDeviceGetter edgeDeviceGetter; + private readonly IChannelPublisher channelPublisher; private readonly ILogger log; - public SendCloudToDeviceMessage(ILoRaDeviceCacheStore cacheStore, IDeviceRegistryManager registryManager, IServiceClient serviceClient, ILogger log) + public SendCloudToDeviceMessage(ILoRaDeviceCacheStore cacheStore, + IDeviceRegistryManager registryManager, + IServiceClient serviceClient, + IEdgeDeviceGetter edgeDeviceGetter, + IChannelPublisher channelPublisher, + ILogger log) { this.cacheStore = cacheStore; this.registryManager = registryManager; this.serviceClient = serviceClient; + this.edgeDeviceGetter = edgeDeviceGetter; + this.channelPublisher = channelPublisher; this.log = log; } [FunctionName("SendCloudToDeviceMessage")] public async Task Run( [HttpTrigger(AuthorizationLevel.Function, "post", Route = "cloudtodevicemessage/{devEUI}")] HttpRequest req, - string devEUI) + string devEUI, + CancellationToken cancellationToken) { DevEui parsedDevEui; @@ -78,10 +89,10 @@ public async Task Run( var c2dMessage = JsonConvert.DeserializeObject(requestBody); c2dMessage.DevEUI = parsedDevEui; - return await SendCloudToDeviceMessageImplementationAsync(parsedDevEui, c2dMessage); + return await SendCloudToDeviceMessageImplementationAsync(parsedDevEui, c2dMessage, cancellationToken); } - public async Task SendCloudToDeviceMessageImplementationAsync(DevEui devEUI, LoRaCloudToDeviceMessage c2dMessage) + public async Task SendCloudToDeviceMessageImplementationAsync(DevEui devEUI, LoRaCloudToDeviceMessage c2dMessage, CancellationToken cancellationToken) { if (c2dMessage == null) { @@ -96,7 +107,7 @@ public async Task SendCloudToDeviceMessageImplementationAsync(Dev var cachedPreferredGateway = LoRaDevicePreferredGateway.LoadFromCache(this.cacheStore, devEUI); if (cachedPreferredGateway != null && !string.IsNullOrEmpty(cachedPreferredGateway.GatewayID)) { - return await SendMessageViaDirectMethodAsync(cachedPreferredGateway.GatewayID, devEUI, c2dMessage); + return await SendMessageViaDirectMethodOrPubSubAsync(cachedPreferredGateway.GatewayID, devEUI, c2dMessage, cancellationToken); } var queryText = $"SELECT * FROM devices WHERE deviceId = '{devEUI}'"; @@ -137,7 +148,7 @@ public async Task SendCloudToDeviceMessageImplementationAsync(Dev var preferredGateway = new LoRaDevicePreferredGateway(gatewayID, 0); _ = LoRaDevicePreferredGateway.SaveToCache(this.cacheStore, devEUI, preferredGateway, onlyIfNotExists: true); - return await SendMessageViaDirectMethodAsync(gatewayID, devEUI, c2dMessage); + return await SendMessageViaDirectMethodOrPubSubAsync(gatewayID, devEUI, c2dMessage, cancellationToken); } // class c device that did not send a single upstream message @@ -189,21 +200,44 @@ private async Task SendMessageViaCloudToDeviceMessageAsync(DevEui } } - private async Task SendMessageViaDirectMethodAsync( + private async Task SendMessageViaDirectMethodOrPubSubAsync( string preferredGatewayID, DevEui devEUI, - LoRaCloudToDeviceMessage c2dMessage) + LoRaCloudToDeviceMessage c2dMessage, + CancellationToken cancellationToken) { try { var method = new CloudToDeviceMethod(LoraKeysManagerFacadeConstants.CloudToDeviceMessageMethodName, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1)); - _ = method.SetPayloadJson(JsonConvert.SerializeObject(c2dMessage)); - // class c devices are always listening - we want to publish message to redis queue here - // call IChannelPublisher.PublishAsync - var res = await this.serviceClient.InvokeDeviceMethodAsync(preferredGatewayID, LoraKeysManagerFacadeConstants.NetworkServerModuleId, method); - if (HttpUtilities.IsSuccessStatusCode(res.Status)) + var jsonContent = JsonConvert.SerializeObject(c2dMessage); + _ = method.SetPayloadJson(jsonContent); + + if (await edgeDeviceGetter.IsEdgeDeviceAsync(preferredGatewayID, cancellationToken)) + { + var res = await this.serviceClient.InvokeDeviceMethodAsync(preferredGatewayID, LoraKeysManagerFacadeConstants.NetworkServerModuleId, method); + if (HttpUtilities.IsSuccessStatusCode(res.Status)) + { + this.log.LogInformation("Direct method call to {gatewayID} and {devEUI} succeeded with {statusCode}", preferredGatewayID, devEUI, res.Status); + + return new OkObjectResult(new SendCloudToDeviceMessageResult() + { + DevEui = devEUI, + MessageID = c2dMessage.MessageId, + ClassType = "C", + }); + } + + this.log.LogError("Direct method call to {gatewayID} failed with {statusCode}. Response: {response}", preferredGatewayID, res.Status, res.GetPayloadAsJson()); + + return new ObjectResult(res.GetPayloadAsJson()) + { + StatusCode = res.Status, + }; + } + else { - this.log.LogInformation("Direct method call to {gatewayID} and {devEUI} succeeded with {statusCode}", preferredGatewayID, devEUI, res.Status); + await this.channelPublisher.PublishAsync(preferredGatewayID, new LnsRemoteCall(RemoteCallKind.CloudToDeviceMessage, jsonContent)); + this.log.LogInformation("C2D message to {gatewayID} and {devEUI} published to Redis queue", preferredGatewayID, devEUI); return new OkObjectResult(new SendCloudToDeviceMessageResult() { @@ -212,13 +246,6 @@ private async Task SendMessageViaDirectMethodAsync( ClassType = "C", }); } - - this.log.LogError("Direct method call to {gatewayID} failed with {statusCode}. Response: {response}", preferredGatewayID, res.Status, res.GetPayloadAsJson()); - - return new ObjectResult(res.GetPayloadAsJson()) - { - StatusCode = res.Status, - }; } catch (JsonSerializationException ex) { diff --git a/Tests/Integration/DeduplicationWithRedisIntegrationTests.cs b/Tests/Integration/DeduplicationWithRedisIntegrationTests.cs index be5213783c..46f6923594 100644 --- a/Tests/Integration/DeduplicationWithRedisIntegrationTests.cs +++ b/Tests/Integration/DeduplicationWithRedisIntegrationTests.cs @@ -6,9 +6,11 @@ namespace LoRaWan.Tests.Integration { using System; + using System.Threading; using System.Threading.Tasks; using LoraKeysManagerFacade; using LoraKeysManagerFacade.FunctionBundler; + using LoRaTools; using LoRaWan.Tests.Common; using Microsoft.ApplicationInsights.Extensibility; using Microsoft.Azure.Devices; @@ -24,6 +26,8 @@ public sealed class DeduplicationTestWithRedis : IClassFixture, ID private readonly ILoRaDeviceCacheStore cache; private readonly Mock serviceClientMock; private readonly TelemetryConfiguration telemetryConfiguration; + private readonly Mock edgeDeviceGetter; + private readonly Mock channelPublisher; private readonly DeduplicationExecutionItem deduplicationExecutionItem; public DeduplicationTestWithRedis(RedisFixture redis) @@ -33,16 +37,27 @@ public DeduplicationTestWithRedis(RedisFixture redis) this.cache = new LoRaDeviceCacheRedisStore(redis.Database); this.serviceClientMock = new Mock(); this.telemetryConfiguration = new TelemetryConfiguration(); - this.deduplicationExecutionItem = new DeduplicationExecutionItem(this.cache, this.serviceClientMock.Object, this.telemetryConfiguration); + this.edgeDeviceGetter = new Mock(); + this.channelPublisher = new Mock(); + this.deduplicationExecutionItem = new DeduplicationExecutionItem(this.cache, + this.serviceClientMock.Object, + this.edgeDeviceGetter.Object, + this.channelPublisher.Object, + this.telemetryConfiguration); } [Theory] - [InlineData("gateway1", 1, "gateway1", 1)] - [InlineData("gateway1", 1, "gateway1", 2)] - [InlineData("gateway1", 1, "gateway2", 1)] - [InlineData("gateway1", 1, "gateway2", 2)] - public async Task When_Called_Multiple_Times_With_Same_Device_Should_Detect_Duplicates(string gateway1, uint fcnt1, string gateway2, uint fcnt2) + [InlineData("gateway1", 1, "gateway1", 1, true)] + [InlineData("gateway1", 1, "gateway1", 2, true)] + [InlineData("gateway1", 1, "gateway2", 1, true)] + [InlineData("gateway1", 1, "gateway2", 2, true)] + [InlineData("gateway1", 1, "gateway1", 1, false)] + [InlineData("gateway1", 1, "gateway1", 2, false)] + [InlineData("gateway1", 1, "gateway2", 1, false)] + [InlineData("gateway1", 1, "gateway2", 2, false)] + public async Task When_Called_Multiple_Times_With_Same_Device_Should_Detect_Duplicates_Direct_Method_Or_Pub_Sub(string gateway1, uint fcnt1, string gateway2, uint fcnt2, bool isEdgeDevice) { + this.edgeDeviceGetter.Setup(m => m.IsEdgeDeviceAsync(It.IsAny(), It.IsAny())).ReturnsAsync(isEdgeDevice); this.serviceClientMock.Setup( x => x.InvokeDeviceMethodAsync(It.IsAny(), LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.IsAny())) .ReturnsAsync(new CloudToDeviceMethodResult() { Status = 200 }); @@ -81,10 +96,18 @@ public async Task When_Called_Multiple_Times_With_Same_Device_Should_Detect_Dupl Assert.Equal(FunctionBundlerExecutionState.Continue, res2); Assert.False(pipeline2.Result.DeduplicationResult.IsDuplicate); - // gateway1 should be notified that it needs to drop connection for the device - this.serviceClientMock.Verify(x => x.InvokeDeviceMethodAsync(gateway1, LoraKeysManagerFacadeConstants.NetworkServerModuleId, - It.Is(m => m.MethodName == LoraKeysManagerFacadeConstants.CloudToDeviceCloseConnection - && m.GetPayloadAsJson().Contains(devEUI.ToString())))); + if (isEdgeDevice) + { + // gateway1 should be notified that it needs to drop connection for the device + this.serviceClientMock.Verify(x => x.InvokeDeviceMethodAsync(gateway1, LoraKeysManagerFacadeConstants.NetworkServerModuleId, + It.Is(m => m.MethodName == LoraKeysManagerFacadeConstants.CloudToDeviceCloseConnection + && m.GetPayloadAsJson().Contains(devEUI.ToString())))); + } + else + { + this.channelPublisher.Verify(x => x.PublishAsync(gateway1, It.Is(c => c.Kind == RemoteCallKind.CloseConnection))); + this.serviceClientMock.VerifyNoOtherCalls(); + } } } diff --git a/Tests/Unit/LoRaTools/ApiVersionTest.cs b/Tests/Unit/LoRaTools/ApiVersionTest.cs index ee4e1842cf..abd3e67cf9 100644 --- a/Tests/Unit/LoRaTools/ApiVersionTest.cs +++ b/Tests/Unit/LoRaTools/ApiVersionTest.cs @@ -32,7 +32,7 @@ public async Task LatestVersion_Returns_Bad_Request_If_InvalidVersion_Requested( (req) => new DeviceGetter(null, null, NullLogger.Instance).GetDevice(req), (req) => Task.Run(() => new FCntCacheCheck(null, NullLogger.Instance).NextFCntDownInvoke(req)), (req) => Task.Run(() => new FunctionBundlerFunction(Array.Empty(), NullLogger.Instance).FunctionBundler(req, string.Empty)), - (req) => new SendCloudToDeviceMessage(null, null, null, null).Run(req, string.Empty) + (req) => new SendCloudToDeviceMessage(null, null, null, null, null, null).Run(req, string.Empty, default) }; foreach (var apiCall in apiCalls) diff --git a/Tests/Unit/LoraKeysManagerFacade/FunctionBundlerTest.cs b/Tests/Unit/LoraKeysManagerFacade/FunctionBundlerTest.cs index 30258686d4..85985542a2 100644 --- a/Tests/Unit/LoraKeysManagerFacade/FunctionBundlerTest.cs +++ b/Tests/Unit/LoraKeysManagerFacade/FunctionBundlerTest.cs @@ -58,7 +58,11 @@ public FunctionBundlerTest() this.telemetryConfiguration = new TelemetryConfiguration(); var items = new IFunctionBundlerExecutionItem[] { - new DeduplicationExecutionItem(cacheStore, Mock.Of(), this.telemetryConfiguration), + new DeduplicationExecutionItem(cacheStore, + Mock.Of(), + Mock.Of(), + Mock.Of(), + this.telemetryConfiguration), this.adrExecutionItem, new NextFCntDownExecutionItem(new FCntCacheCheck(cacheStore, NullLogger.Instance)), new PreferredGatewayExecutionItem(cacheStore, new NullLogger(), null), @@ -339,7 +343,11 @@ public void Execution_Items_Should_Have_Correct_Priority() var items = new IFunctionBundlerExecutionItem[] { - new DeduplicationExecutionItem(cacheStore, Mock.Of(), this.telemetryConfiguration), + new DeduplicationExecutionItem(cacheStore, + Mock.Of(), + Mock.Of(), + Mock.Of(), + this.telemetryConfiguration), new ADRExecutionItem(this.adrManager), new NextFCntDownExecutionItem(new FCntCacheCheck(cacheStore, NullLogger.Instance)), new PreferredGatewayExecutionItem(cacheStore, new NullLogger(), null), diff --git a/Tests/Unit/LoraKeysManagerFacade/MessageDeduplicationTests.cs b/Tests/Unit/LoraKeysManagerFacade/MessageDeduplicationTests.cs index 42b726cb55..72fc1260f2 100644 --- a/Tests/Unit/LoraKeysManagerFacade/MessageDeduplicationTests.cs +++ b/Tests/Unit/LoraKeysManagerFacade/MessageDeduplicationTests.cs @@ -4,6 +4,7 @@ namespace LoRaWan.Tests.Unit.LoraKeysManagerFacade.FunctionBundler { using System; + using System.Threading; using System.Threading.Tasks; using global::LoraKeysManagerFacade; using global::LoraKeysManagerFacade.FunctionBundler; @@ -19,13 +20,21 @@ public sealed class MessageDeduplicationTests : FunctionTestBase, IDisposable private readonly DeduplicationExecutionItem deduplicationExecutionItem; private readonly Mock serviceClientMock; private readonly TelemetryConfiguration telemetryConfiguration; + private readonly Mock edgeDeviceGetter; public MessageDeduplicationTests() { this.serviceClientMock = new Mock(); this.telemetryConfiguration = new TelemetryConfiguration(); - this.deduplicationExecutionItem = new DeduplicationExecutionItem(new LoRaInMemoryDeviceStore(), this.serviceClientMock.Object, this.telemetryConfiguration); + this.edgeDeviceGetter = new Mock(); + this.edgeDeviceGetter.Setup(m => m.IsEdgeDeviceAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + + this.deduplicationExecutionItem = new DeduplicationExecutionItem(new LoRaInMemoryDeviceStore(), + this.serviceClientMock.Object, + this.edgeDeviceGetter.Object, + Mock.Of(), + this.telemetryConfiguration); } [Fact] diff --git a/Tests/Unit/LoraKeysManagerFacade/SendCloudToDeviceMessageTest.cs b/Tests/Unit/LoraKeysManagerFacade/SendCloudToDeviceMessageTest.cs index 95f7af9cf5..bb93557aee 100644 --- a/Tests/Unit/LoraKeysManagerFacade/SendCloudToDeviceMessageTest.cs +++ b/Tests/Unit/LoraKeysManagerFacade/SendCloudToDeviceMessageTest.cs @@ -8,6 +8,7 @@ namespace LoRaWan.Tests.Unit.LoraKeysManagerFacade using System.IO; using System.Net; using System.Text; + using System.Threading; using System.Threading.Tasks; using global::LoraKeysManagerFacade; using global::LoRaTools; @@ -30,6 +31,8 @@ public class SendCloudToDeviceMessageTest private readonly LoRaInMemoryDeviceStore cacheStore; private readonly Mock serviceClient; private readonly Mock registryManager; + private readonly Mock edgeDeviceGetter; + private readonly Mock channelPublisher; private readonly SendCloudToDeviceMessage sendCloudToDeviceMessage; public SendCloudToDeviceMessageTest() @@ -37,7 +40,14 @@ public SendCloudToDeviceMessageTest() this.cacheStore = new LoRaInMemoryDeviceStore(); this.serviceClient = new Mock(MockBehavior.Strict); this.registryManager = new Mock(MockBehavior.Strict); - this.sendCloudToDeviceMessage = new SendCloudToDeviceMessage(this.cacheStore, this.registryManager.Object, this.serviceClient.Object, new NullLogger()); + this.edgeDeviceGetter = new Mock(); + this.channelPublisher = new Mock(); + this.sendCloudToDeviceMessage = new SendCloudToDeviceMessage(this.cacheStore, + this.registryManager.Object, + this.serviceClient.Object, + this.edgeDeviceGetter.Object, + this.channelPublisher.Object, + new NullLogger()); } [Theory] @@ -53,7 +63,7 @@ public async Task When_DevEUI_Is_Missing_Should_Return_BadRequest(string devEUI) var request = new DefaultHttpContext().Request; request.Body = new MemoryStream(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(c2dMessage))); - var result = await this.sendCloudToDeviceMessage.Run(request, devEUI); + var result = await this.sendCloudToDeviceMessage.Run(request, devEUI, default); Assert.IsType(result); @@ -64,7 +74,7 @@ public async Task When_DevEUI_Is_Missing_Should_Return_BadRequest(string devEUI) [Fact] public async Task When_Request_Is_Missing_Should_Return_BadRequest() { - var actual = await this.sendCloudToDeviceMessage.Run(null, new DevEui(123456789).ToString()); + var actual = await this.sendCloudToDeviceMessage.Run(null, new DevEui(123456789).ToString(), default); Assert.IsType(actual); @@ -77,7 +87,8 @@ public async Task When_Message_Is_Missing_Should_Return_BadRequest() { var actual = await this.sendCloudToDeviceMessage.SendCloudToDeviceMessageImplementationAsync( new DevEui(123456789), - null); + null, + default); Assert.IsType(actual); @@ -98,7 +109,8 @@ public async Task When_Message_Is_Invalid_Should_Return_BadRequest() var actual = await this.sendCloudToDeviceMessage.SendCloudToDeviceMessageImplementationAsync( devEui, - c2dMessage); + c2dMessage, + default); Assert.IsType(actual); @@ -106,9 +118,13 @@ public async Task When_Message_Is_Invalid_Should_Return_BadRequest() this.registryManager.VerifyAll(); } - [Fact] - public async Task When_Device_Is_Found_In_Cache_Should_Send_Via_Direct_Method() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task When_Device_Is_Found_In_Cache_Should_Send_Via_Direct_Method_Or_Pub_Sub(bool isEdgeDevice) { + this.edgeDeviceGetter.Setup(m => m.IsEdgeDeviceAsync(It.IsAny(), It.IsAny())).ReturnsAsync(isEdgeDevice); + var devEui = new DevEui(123456789); var preferredGateway = new LoRaDevicePreferredGateway("gateway1", 100); LoRaDevicePreferredGateway.SaveToCache(this.cacheStore, devEui, preferredGateway); @@ -120,13 +136,23 @@ public async Task When_Device_Is_Found_In_Cache_Should_Send_Via_Direct_Method() }; LoRaCloudToDeviceMessage receivedC2DMessage = null; - this.serviceClient.Setup(x => x.InvokeDeviceMethodAsync("gateway1", LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.IsNotNull())) - .Callback((device, methodName, method) => receivedC2DMessage = JsonConvert.DeserializeObject(method.GetPayloadAsJson())) - .ReturnsAsync(new CloudToDeviceMethodResult() { Status = (int)HttpStatusCode.OK }); + + if (isEdgeDevice) + { + this.serviceClient.Setup(x => x.InvokeDeviceMethodAsync("gateway1", LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.IsNotNull())) + .Callback((device, methodName, method) => receivedC2DMessage = JsonConvert.DeserializeObject(method.GetPayloadAsJson())) + .ReturnsAsync(new CloudToDeviceMethodResult() { Status = (int)HttpStatusCode.OK }); + } + else + { + this.channelPublisher.Setup(x => x.PublishAsync("gateway1", It.IsNotNull())) + .Callback((device, remoteCall) => receivedC2DMessage = JsonConvert.DeserializeObject(remoteCall.JsonData)); + } var actual = await this.sendCloudToDeviceMessage.SendCloudToDeviceMessageImplementationAsync( devEui, - actualMessage); + actualMessage, + default); Assert.IsType(actual); var responseValue = ((OkObjectResult)actual).Value as SendCloudToDeviceMessageResult; @@ -144,6 +170,8 @@ public async Task When_Device_Is_Found_In_Cache_Should_Send_Via_Direct_Method() [Fact] public async Task When_Direct_Method_Returns_Error_Code_Should_Forward_Status_Error() { + this.edgeDeviceGetter.Setup(m => m.IsEdgeDeviceAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + var devEui = new DevEui(0123456789); var preferredGateway = new LoRaDevicePreferredGateway("gateway1", 100); LoRaDevicePreferredGateway.SaveToCache(this.cacheStore, devEui, preferredGateway); @@ -156,7 +184,7 @@ public async Task When_Direct_Method_Returns_Error_Code_Should_Forward_Status_Er new LoRaCloudToDeviceMessage() { Fport = TestPort, - }); + }, default); Assert.IsType(actual); Assert.Equal((int)HttpStatusCode.BadRequest, ((ObjectResult)actual).StatusCode); @@ -168,6 +196,8 @@ public async Task When_Direct_Method_Returns_Error_Code_Should_Forward_Status_Er [Fact] public async Task When_Direct_Method_Throws_Exception_Should_Return_Application_Error() { + this.edgeDeviceGetter.Setup(m => m.IsEdgeDeviceAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + var devEui = new DevEui(123456789); var preferredGateway = new LoRaDevicePreferredGateway("gateway1", 100); LoRaDevicePreferredGateway.SaveToCache(this.cacheStore, devEui, preferredGateway); @@ -180,7 +210,7 @@ public async Task When_Direct_Method_Throws_Exception_Should_Return_Application_ new LoRaCloudToDeviceMessage() { Fport = TestPort, - }); + }, default); Assert.IsType(actual); Assert.Equal((int)HttpStatusCode.InternalServerError, ((ObjectResult)actual).StatusCode); @@ -207,7 +237,7 @@ public async Task When_Device_Does_Not_Have_DevAddr_Should_Return_BadRequest() new LoRaCloudToDeviceMessage() { Fport = TestPort, - }); + }, default); Assert.IsType(actual); @@ -234,7 +264,7 @@ public async Task When_Querying_Devices_Throws_Exception_Should_Return_Applicati new LoRaCloudToDeviceMessage() { Fport = TestPort, - }); + }, default); Assert.IsType(actual); Assert.Equal((int)HttpStatusCode.InternalServerError, ((ObjectResult)actual).StatusCode); @@ -262,7 +292,7 @@ public async Task When_Querying_Devices_Is_Empty_Should_Return_NotFound() new LoRaCloudToDeviceMessage() { Fport = TestPort, - }); + }, default); Assert.IsType(actual); Assert.Equal((int)HttpStatusCode.NotFound, ((ObjectResult)actual).StatusCode); @@ -272,9 +302,13 @@ public async Task When_Querying_Devices_Is_Empty_Should_Return_NotFound() query.VerifyAll(); } - [Fact] - public async Task When_Querying_Devices_And_Finds_Class_C_Should_Update_Cache_And_Send_Direct_Method() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task When_Querying_Devices_And_Finds_Class_C_Should_Update_Cache_And_Send_Direct_Method_Or_Pub_Sub(bool isEdgeDevice) { + this.edgeDeviceGetter.Setup(m => m.IsEdgeDeviceAsync(It.IsAny(), It.IsAny())).ReturnsAsync(isEdgeDevice); + var devEui = new DevEui(123456789); var deviceTwin = new Twin @@ -301,13 +335,23 @@ public async Task When_Querying_Devices_And_Finds_Class_C_Should_Update_Cache_An }; LoRaCloudToDeviceMessage receivedC2DMessage = null; - this.serviceClient.Setup(x => x.InvokeDeviceMethodAsync("gateway1", LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.IsNotNull())) - .Callback((device, methodName, method) => receivedC2DMessage = JsonConvert.DeserializeObject(method.GetPayloadAsJson())) - .ReturnsAsync(new CloudToDeviceMethodResult() { Status = (int)HttpStatusCode.OK }); + + if (isEdgeDevice) + { + this.serviceClient.Setup(x => x.InvokeDeviceMethodAsync("gateway1", LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.IsNotNull())) + .Callback((device, methodName, method) => receivedC2DMessage = JsonConvert.DeserializeObject(method.GetPayloadAsJson())) + .ReturnsAsync(new CloudToDeviceMethodResult() { Status = (int)HttpStatusCode.OK }); + } + else + { + this.channelPublisher.Setup(x => x.PublishAsync("gateway1", It.IsNotNull())) + .Callback((device, remoteCall) => receivedC2DMessage = JsonConvert.DeserializeObject(remoteCall.JsonData)); + } + var actual = await this.sendCloudToDeviceMessage.SendCloudToDeviceMessageImplementationAsync( devEui, - actualMessage); + actualMessage, default); Assert.IsType(actual); var responseValue = ((OkObjectResult)actual).Value as SendCloudToDeviceMessageResult; @@ -327,9 +371,13 @@ public async Task When_Querying_Devices_And_Finds_Class_C_Should_Update_Cache_An query.VerifyAll(); } - [Fact] - public async Task When_Querying_Devices_And_Finds_Single_Gateway_Class_C_Should_Update_Cache_And_Send_Direct_Method() + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task When_Querying_Devices_And_Finds_Single_Gateway_Class_C_Should_Update_Cache_And_Send_Direct_Method_Or_Pub_Sub(bool isEdgeDevice) { + this.edgeDeviceGetter.Setup(m => m.IsEdgeDeviceAsync(It.IsAny(), It.IsAny())).ReturnsAsync(isEdgeDevice); var devEui = new DevEui(123456789); var deviceTwin = new Twin @@ -356,13 +404,21 @@ public async Task When_Querying_Devices_And_Finds_Single_Gateway_Class_C_Should_ }; LoRaCloudToDeviceMessage receivedC2DMessage = null; - this.serviceClient.Setup(x => x.InvokeDeviceMethodAsync("mygateway", LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.IsNotNull())) - .Callback((device, methodName, method) => receivedC2DMessage = JsonConvert.DeserializeObject(method.GetPayloadAsJson())) - .ReturnsAsync(new CloudToDeviceMethodResult() { Status = (int)HttpStatusCode.OK }); + if (isEdgeDevice) + { + this.serviceClient.Setup(x => x.InvokeDeviceMethodAsync("mygateway", LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.IsNotNull())) + .Callback((device, methodName, method) => receivedC2DMessage = JsonConvert.DeserializeObject(method.GetPayloadAsJson())) + .ReturnsAsync(new CloudToDeviceMethodResult() { Status = (int)HttpStatusCode.OK }); + } + else + { + this.channelPublisher.Setup(x => x.PublishAsync("mygateway", It.IsNotNull())) + .Callback((device, remoteCall) => receivedC2DMessage = JsonConvert.DeserializeObject(remoteCall.JsonData)); + } var actual = await this.sendCloudToDeviceMessage.SendCloudToDeviceMessageImplementationAsync( devEui, - actualMessage); + actualMessage, default); Assert.IsType(actual); var responseValue = ((OkObjectResult)actual).Value as SendCloudToDeviceMessageResult; @@ -413,7 +469,7 @@ public async Task When_Querying_Devices_And_Finds_No_Gateway_For_Class_C_Should_ var actual = await this.sendCloudToDeviceMessage.SendCloudToDeviceMessageImplementationAsync( devEui, - actualMessage); + actualMessage, default); var result = Assert.IsType(actual); Assert.Equal(500, result.StatusCode); @@ -460,7 +516,7 @@ public async Task When_Querying_Devices_And_Finds_Class_A_Should_Send_Message() var actual = await this.sendCloudToDeviceMessage.SendCloudToDeviceMessageImplementationAsync( devEui, - actualMessage); + actualMessage, default); Assert.IsType(actual); var responseValue = ((OkObjectResult)actual).Value as SendCloudToDeviceMessageResult; @@ -512,7 +568,7 @@ public async Task When_Sending_Message_Throws_Error_Should_Return_Application_Er var actual = await this.sendCloudToDeviceMessage.SendCloudToDeviceMessageImplementationAsync( devEui, - actualMessage); + actualMessage, default); Assert.IsType(actual); Assert.Equal((int)HttpStatusCode.InternalServerError, ((ObjectResult)actual).StatusCode); From 25fe964d2da967dbcc7647af52c2a4d70eb2157a Mon Sep 17 00:00:00 2001 From: Patrick Schuler Date: Wed, 18 May 2022 22:43:11 +0200 Subject: [PATCH 12/22] Bug/1708 flaky lo ra device cache test test (#1709) Co-authored-by: Patrick Schuler --- .../Unit/NetworkServer/LoRaDeviceCacheTest.cs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/Tests/Unit/NetworkServer/LoRaDeviceCacheTest.cs b/Tests/Unit/NetworkServer/LoRaDeviceCacheTest.cs index 95879c8219..b5b4b7cd5d 100644 --- a/Tests/Unit/NetworkServer/LoRaDeviceCacheTest.cs +++ b/Tests/Unit/NetworkServer/LoRaDeviceCacheTest.cs @@ -17,7 +17,6 @@ namespace LoRaWan.Tests.Unit.NetworkServer public class LoRaDeviceCacheTest { - [Fact] public async Task When_Device_Expires_It_Is_Refreshed() { @@ -57,7 +56,7 @@ public async Task When_Device_Is_Fresh_No_Refresh_Is_Triggered() device.LastUpdate = DateTime.UtcNow + TimeSpan.FromMinutes(1); cache.Register(device); - using var cts = new CancellationTokenSource(this.quickRefreshOptions.ValidationInterval * 2); + using var cts = this.quickRefreshOptions.ValidationIntervalCancellationToken(); await Assert.ThrowsAsync(() => cache.WaitForRefreshAsync(cts.Token)); } @@ -83,7 +82,7 @@ public async Task When_Disposed_While_Refreshing_We_Shutdown_Gracefully() await cache.DisposeAsync(); var count = cache.DeviceRefreshCount; - await Task.Delay(this.quickRefreshOptions.ValidationInterval * 2); + await Task.Delay(this.quickRefreshOptions.ValidationIntervalDelay()); Assert.Equal(count, cache.DeviceRefreshCount); } @@ -118,7 +117,7 @@ public async Task When_Device_Inactive_It_Is_Removed() await using var device = new LoRaDevice(new DevAddr(0xabc), new DevEui(0x123), connectionManager.Object) { LastSeen = DateTime.UtcNow }; cache.Register(device); - using var cts = new CancellationTokenSource(this.quickRefreshOptions.ValidationInterval * 2); + using var cts = this.quickRefreshOptions.ValidationIntervalCancellationToken(); await cache.WaitForRemoveAsync(cts.Token); Assert.False(cache.TryGetByDevEui(device.DevEUI, out _)); @@ -450,11 +449,9 @@ protected override void OnRefresh() public override async Task RemoveAsync(LoRaDevice device) { + var ret = await base.RemoveAsync(device); if (this.removeTick.CurrentCount == 0) this.removeTick.Release(); - - var ret = await base.RemoveAsync(device); - return ret; } @@ -474,4 +471,11 @@ protected override ValueTask DisposeAsync(bool dispose) } } } + internal static class LoRaDeviceCacheOptionsExtensions + { + public static TimeSpan ValidationIntervalDelay(this LoRaDeviceCacheOptions options) + => options.ValidationInterval * 3; + + public static CancellationTokenSource ValidationIntervalCancellationToken(this LoRaDeviceCacheOptions options) => new CancellationTokenSource(options.ValidationIntervalDelay()); + } } From e705fab2f3a33ec8d59ab6a03f173f98651e16a2 Mon Sep 17 00:00:00 2001 From: Daniele Antonio Maggio <1955514+danigian@users.noreply.github.com> Date: Thu, 19 May 2022 11:10:44 -0700 Subject: [PATCH 13/22] Bringing latest changes of dev branch in ocw-edge/dev (#1718) --- Directory.Build.props | 2 +- .../PreferredGatewayExecutionItem.cs | 8 ++--- .../PreferredGatewayResult.cs | 9 ++---- .../DefaultClassCDevicesMessageSender.cs | 2 +- .../FunctionBundler/PreferredGatewayResult.cs | 10 ------- Tests/E2E/LnsDiscoveryTests.cs | 4 +++ Tests/Integration/ClassCIntegrationTests.cs | 6 ++-- .../PreferredGatewayResultTests.cs | 30 +++++++++++++++++++ 8 files changed, 44 insertions(+), 27 deletions(-) create mode 100644 Tests/Unit/NetworkServer/PreferredGatewayResultTests.cs diff --git a/Directory.Build.props b/Directory.Build.props index 8955c820d2..ffbe2e6902 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -32,7 +32,7 @@ 3.125.5 17.2.0 - 4.18.0 + 4.18.1 2.4.1 1.4.1 6.0.1 diff --git a/LoRaEngine/LoraKeysManagerFacade/CheckPreferredGateway/PreferredGatewayExecutionItem.cs b/LoRaEngine/LoraKeysManagerFacade/CheckPreferredGateway/PreferredGatewayExecutionItem.cs index eda178b369..f57670ec81 100644 --- a/LoRaEngine/LoraKeysManagerFacade/CheckPreferredGateway/PreferredGatewayExecutionItem.cs +++ b/LoRaEngine/LoraKeysManagerFacade/CheckPreferredGateway/PreferredGatewayExecutionItem.cs @@ -101,7 +101,7 @@ private async Task ComputePreferredGateway(IPipelineExec { if (preferredGateway.FcntUp >= fcntUp) { - return new PreferredGatewayResult(devEUI, fcntUp, preferredGateway); + return new PreferredGatewayResult(fcntUp, preferredGateway); } } @@ -126,7 +126,7 @@ private async Task ComputePreferredGateway(IPipelineExec { this.log.LogError("Could not resolve closest gateway in {devEUI} and {fcntUp}", devEUI, fcntUp); - return new PreferredGatewayResult(devEUI, fcntUp, "Could not resolve closest gateway"); + return new PreferredGatewayResult(fcntUp, "Could not resolve closest gateway"); } preferredGateway = new LoRaDevicePreferredGateway(winner.GatewayID, fcntUp); @@ -155,13 +155,13 @@ private async Task ComputePreferredGateway(IPipelineExec { if (preferredGateway.FcntUp >= fcntUp) { - return new PreferredGatewayResult(devEUI, fcntUp, preferredGateway); + return new PreferredGatewayResult(fcntUp, preferredGateway); } } } this.log.LogError("Could not resolve closest gateway in {devEUI} and {fcntUp}", devEUI, fcntUp); - return new PreferredGatewayResult(devEUI, fcntUp, "Could not resolve closest gateway"); + return new PreferredGatewayResult(fcntUp, "Could not resolve closest gateway"); } } } diff --git a/LoRaEngine/LoraKeysManagerFacade/CheckPreferredGateway/PreferredGatewayResult.cs b/LoRaEngine/LoraKeysManagerFacade/CheckPreferredGateway/PreferredGatewayResult.cs index 8e36240394..6f0d5af3f0 100644 --- a/LoRaEngine/LoraKeysManagerFacade/CheckPreferredGateway/PreferredGatewayResult.cs +++ b/LoRaEngine/LoraKeysManagerFacade/CheckPreferredGateway/PreferredGatewayResult.cs @@ -3,7 +3,6 @@ namespace LoraKeysManagerFacade { - using LoRaWan; using Newtonsoft.Json; using System; @@ -12,8 +11,6 @@ namespace LoraKeysManagerFacade /// public class PreferredGatewayResult { - public DevEui DevEUI { get; } - public uint RequestFcntUp { get; } [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] @@ -38,20 +35,18 @@ public PreferredGatewayResult() { } - public PreferredGatewayResult(DevEui devEUI, uint fcntUp, LoRaDevicePreferredGateway preferredGateway) + public PreferredGatewayResult(uint fcntUp, LoRaDevicePreferredGateway preferredGateway) { if (preferredGateway is null) throw new ArgumentNullException(nameof(preferredGateway)); - DevEUI = devEUI; RequestFcntUp = fcntUp; CurrentFcntUp = preferredGateway.FcntUp; PreferredGatewayID = preferredGateway.GatewayID; Conflict = fcntUp != preferredGateway.FcntUp; } - public PreferredGatewayResult(DevEui devEUI, uint fcntUp, string errorMessage) + public PreferredGatewayResult(uint fcntUp, string errorMessage) { - DevEUI = devEUI; RequestFcntUp = fcntUp; ErrorMessage = errorMessage; } diff --git a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/DefaultClassCDevicesMessageSender.cs b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/DefaultClassCDevicesMessageSender.cs index 4a10625588..44d016358c 100644 --- a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/DefaultClassCDevicesMessageSender.cs +++ b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/DefaultClassCDevicesMessageSender.cs @@ -96,7 +96,7 @@ public async Task SendAsync(IReceivedLoRaCloudToDeviceMessage message, Can return false; } - var fcntDown = await frameCounterStrategy.NextFcntDown(loRaDevice, 0); + var fcntDown = await frameCounterStrategy.NextFcntDown(loRaDevice, loRaDevice.FCntUp); if (fcntDown <= 0) { this.logger.LogError("[class-c] could not obtain fcnt down for class C device"); diff --git a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/FunctionBundler/PreferredGatewayResult.cs b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/FunctionBundler/PreferredGatewayResult.cs index e7e7d80d0e..97b23d95e7 100644 --- a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/FunctionBundler/PreferredGatewayResult.cs +++ b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/FunctionBundler/PreferredGatewayResult.cs @@ -10,16 +10,6 @@ namespace LoRaWan.NetworkServer /// public class PreferredGatewayResult { - [JsonIgnore] - public DevEui DevEUI { get; set; } - - [JsonProperty("DevEUI")] - public string DevEuiString - { - get => DevEUI.ToString(); - set => DevEUI = DevEui.Parse(value); - } - public uint RequestFcntUp { get; set; } [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] diff --git a/Tests/E2E/LnsDiscoveryTests.cs b/Tests/E2E/LnsDiscoveryTests.cs index 5bf80ad02a..522599e8a8 100644 --- a/Tests/E2E/LnsDiscoveryTests.cs +++ b/Tests/E2E/LnsDiscoveryTests.cs @@ -91,6 +91,10 @@ public async Task InitializeAsync() await this.registryManager.UpdateTwinAsync(deviceId, new Twin { Tags = GetNetworkTags(station.NetworkId) }, "*", CancellationToken.None); } + var waitTime = TimeSpan.FromSeconds(60); + Console.WriteLine($"Waiting for {waitTime.TotalSeconds} seconds."); + await Task.Delay(waitTime); + static TwinCollection GetNetworkTags(string networkId) => new TwinCollection(JsonSerializer.Serialize(new { network = networkId })); } } diff --git a/Tests/Integration/ClassCIntegrationTests.cs b/Tests/Integration/ClassCIntegrationTests.cs index a622827ab5..dab6029003 100644 --- a/Tests/Integration/ClassCIntegrationTests.cs +++ b/Tests/Integration/ClassCIntegrationTests.cs @@ -129,7 +129,7 @@ public async Task When_ABP_Sends_Upstream_Followed_By_DirectMethod_Should_Send_U if (string.IsNullOrEmpty(deviceGatewayID)) { - LoRaDeviceApi.Setup(x => x.NextFCntDownAsync(simDevice.DevEUI, fcntDownFromTwin + fcntDelta, 0, ServerConfiguration.GatewayID)) + LoRaDeviceApi.Setup(x => x.NextFCntDownAsync(simDevice.DevEUI, fcntDownFromTwin + fcntDelta, simDevice.FrmCntUp, ServerConfiguration.GatewayID)) .ReturnsAsync((ushort)expectedFcntDown); } @@ -234,7 +234,7 @@ public async Task When_OTAA_Join_Then_Sends_Upstream_DirectMethod_Should_Send_Do if (string.IsNullOrEmpty(deviceGatewayID)) { - LoRaDeviceApi.Setup(x => x.NextFCntDownAsync(simDevice.DevEUI, simDevice.FrmCntDown, 0, ServerConfiguration.GatewayID)) + LoRaDeviceApi.Setup(x => x.NextFCntDownAsync(simDevice.DevEUI, simDevice.FrmCntDown, simDevice.FrmCntUp, ServerConfiguration.GatewayID)) .ReturnsAsync((ushort)(simDevice.FrmCntDown + 1)); } @@ -451,7 +451,6 @@ public async Task When_Processing_Data_Request_Should_Compute_Preferred_Gateway_ { PreferredGatewayResult = new PreferredGatewayResult() { - DevEUI = simulatedDevice.DevEUI, PreferredGatewayID = preferredGatewayID, CurrentFcntUp = PayloadFcnt, RequestFcntUp = PayloadFcnt, @@ -547,7 +546,6 @@ public async Task When_Updating_PreferredGateway_And_FcntUp_Should_Save_Twin_Onc { PreferredGatewayResult = new PreferredGatewayResult() { - DevEUI = simulatedDevice.DevEUI, PreferredGatewayID = ServerGatewayID, CurrentFcntUp = PayloadFcnt, RequestFcntUp = PayloadFcnt, diff --git a/Tests/Unit/NetworkServer/PreferredGatewayResultTests.cs b/Tests/Unit/NetworkServer/PreferredGatewayResultTests.cs new file mode 100644 index 0000000000..ba3cdcbc45 --- /dev/null +++ b/Tests/Unit/NetworkServer/PreferredGatewayResultTests.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#nullable enable + +namespace LoRaWan.Tests.Unit.NetworkServer +{ + using Newtonsoft.Json; + using Xunit; + + public sealed class PreferredGatewayResultTests + { + [Fact] + public void Can_Deserialize() + { + // arrange + var input = new global::LoraKeysManagerFacade.PreferredGatewayResult(12, new global::LoraKeysManagerFacade.LoRaDevicePreferredGateway("gateway", 13)); + + // act + var result = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(input)); + + // assert + Assert.Equal(input.RequestFcntUp, result!.RequestFcntUp); + Assert.Equal(input.PreferredGatewayID, result.PreferredGatewayID); + Assert.Equal(input.Conflict, result.Conflict); + Assert.Equal(input.CurrentFcntUp, result.CurrentFcntUp); + Assert.Equal(input.ErrorMessage, result.ErrorMessage); + } + } +} From ff5916af4157451a95aca696bf11d0ee8a40117f Mon Sep 17 00:00:00 2001 From: Daniele Antonio Maggio <1955514+danigian@users.noreply.github.com> Date: Thu, 19 May 2022 13:23:30 -0700 Subject: [PATCH 14/22] Adding Clear Cache endpoint to Facade function (#1716) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Bastian Burger <22341213+bastbu@users.noreply.github.com> --- .../LoraKeysManagerFacade/ClearLnsCache.cs | 96 +++++++++++++++++++ .../LoraKeysManagerFacade/EdgeDeviceGetter.cs | 9 +- .../DeduplicationExecutionItem.cs | 2 +- .../IEdgeDeviceGetter.cs | 2 + .../LoraKeysManagerFacade/IServiceClient.cs | 3 +- .../LoraKeysManagerFacadeConstants.cs | 1 + .../SendCloudToDeviceMessage.cs | 2 +- .../ServiceClientAdapter.cs | 6 +- .../LoRaWan.NetworkServer/CloudControlHost.cs | 14 +-- .../DeduplicationWithRedisIntegrationTests.cs | 4 +- Tests/Unit/LoRaTools/ApiVersionTest.cs | 3 +- .../ClearLnsCacheTest.cs | 82 ++++++++++++++++ .../EdgeDeviceGetterTests.cs | 31 ++++++ .../MessageDeduplicationTests.cs | 10 +- .../SendCloudToDeviceMessageTest.cs | 16 ++-- 15 files changed, 254 insertions(+), 27 deletions(-) create mode 100644 LoRaEngine/LoraKeysManagerFacade/ClearLnsCache.cs create mode 100644 Tests/Unit/LoraKeysManagerFacade/ClearLnsCacheTest.cs diff --git a/LoRaEngine/LoraKeysManagerFacade/ClearLnsCache.cs b/LoRaEngine/LoraKeysManagerFacade/ClearLnsCache.cs new file mode 100644 index 0000000000..8ef1790785 --- /dev/null +++ b/LoRaEngine/LoraKeysManagerFacade/ClearLnsCache.cs @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#nullable enable + +namespace LoraKeysManagerFacade +{ + using System; + using System.Linq; + using System.Threading; + using System.Threading.Tasks; + using LoRaTools; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Mvc; + using Microsoft.Azure.Devices; + using Microsoft.Azure.WebJobs; + using Microsoft.Azure.WebJobs.Extensions.Http; + using Microsoft.Extensions.Logging; + + public sealed class ClearLnsCache + { + private readonly IEdgeDeviceGetter edgeDeviceGetter; + private readonly IServiceClient serviceClient; + private readonly IChannelPublisher channelPublisher; + private readonly ILogger logger; + + public ClearLnsCache(IEdgeDeviceGetter edgeDeviceGetter, + IServiceClient serviceClient, + IChannelPublisher channelPublisher, + ILogger logger) + { + this.edgeDeviceGetter = edgeDeviceGetter; + this.serviceClient = serviceClient; + this.channelPublisher = channelPublisher; + this.logger = logger; + } + + [FunctionName(nameof(ClearNetworkServerCache))] + public async Task ClearNetworkServerCache([HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequest req, CancellationToken cancellationToken) + { + if (req is null) throw new ArgumentNullException(nameof(req)); + + try + { + VersionValidator.Validate(req); + } + catch (IncompatibleVersionException ex) + { + return new BadRequestObjectResult(ex.Message); + } + + await ClearLnsCacheInternalAsync(cancellationToken); + + return new AcceptedResult(); + } + + internal async Task ClearLnsCacheInternalAsync(CancellationToken cancellationToken) + { + this.logger.LogInformation("Clearing device cache for all edge and Pub/Sub channel based Network Servers."); + // Edge device discovery for invoking direct methods + var edgeDevices = await this.edgeDeviceGetter.ListEdgeDevicesAsync(cancellationToken); + if (this.logger.IsEnabled(LogLevel.Debug)) + { + this.logger.LogDebug("Invoking clear cache direct method for following devices: {deviceList}", string.Join(',', edgeDevices)); + } + var tasks = edgeDevices.Select(e => InvokeClearViaDirectMethodAsync(e, cancellationToken)).ToArray(); + // Publishing a single message for all cloud based LNSes + await PublishClearMessageAsync(); + await Task.WhenAll(tasks); + } + + internal async Task PublishClearMessageAsync() + { + await this.channelPublisher.PublishAsync(LoraKeysManagerFacadeConstants.ClearCacheMethodName, new LnsRemoteCall(RemoteCallKind.ClearCache, null)); + this.logger.LogInformation("Cache clear message published on Pub/Sub channel"); + } + + internal async Task InvokeClearViaDirectMethodAsync(string lnsId, CancellationToken cancellationToken) + { + //Reason why the yield is needed is to avoid any potential "synchronous" code that might fail the publishing of a message on the pub/sub channel + await Task.Yield(); + var res = await this.serviceClient.InvokeDeviceMethodAsync(lnsId, + LoraKeysManagerFacadeConstants.NetworkServerModuleId, + new CloudToDeviceMethod(LoraKeysManagerFacadeConstants.ClearCacheMethodName), + cancellationToken); + if (HttpUtilities.IsSuccessStatusCode(res.Status)) + { + this.logger.LogInformation("Cache cleared for {gatewayID} via direct method", lnsId); + } + else + { + throw new InvalidOperationException($"Direct method call to {lnsId} failed with {res.Status}. Response: {res.GetPayloadAsJson()}"); + } + } + } +} diff --git a/LoRaEngine/LoraKeysManagerFacade/EdgeDeviceGetter.cs b/LoRaEngine/LoraKeysManagerFacade/EdgeDeviceGetter.cs index 7d9804e249..862dcb4170 100644 --- a/LoRaEngine/LoraKeysManagerFacade/EdgeDeviceGetter.cs +++ b/LoRaEngine/LoraKeysManagerFacade/EdgeDeviceGetter.cs @@ -5,6 +5,7 @@ namespace LoraKeysManagerFacade { using System; using System.Collections.Generic; + using System.Linq; using System.Threading; using System.Threading.Tasks; using LoRaTools; @@ -32,7 +33,7 @@ private async Task> GetEdgeDevicesAsync(CancellationToken cancellatio #pragma warning restore IDE0060 // Remove unused parameter { this.logger.LogDebug("Getting Azure IoT Edge devices"); - var q = this.registryManager.CreateQuery("SELECT * FROM devices where capabilities.iotEdge = true"); + var q = this.registryManager.CreateQuery($"SELECT * FROM devices.modules where moduleId = '{LoraKeysManagerFacadeConstants.NetworkServerModuleId}'"); var twins = new List(); do { @@ -105,6 +106,12 @@ private async Task RefreshEdgeDevicesCacheAsync(CancellationToken cancellationTo this.lastUpdateTime = DateTimeOffset.UtcNow; } } + + public async Task> ListEdgeDevicesAsync(CancellationToken cancellationToken) + { + var edgeDevices = await GetEdgeDevicesAsync(cancellationToken); + return edgeDevices.Select(e => e.DeviceId).ToList(); + } } internal class DeviceKind diff --git a/LoRaEngine/LoraKeysManagerFacade/FunctionsBundler/DeduplicationExecutionItem.cs b/LoRaEngine/LoraKeysManagerFacade/FunctionsBundler/DeduplicationExecutionItem.cs index 498aa5fdbb..945120f294 100644 --- a/LoRaEngine/LoraKeysManagerFacade/FunctionsBundler/DeduplicationExecutionItem.cs +++ b/LoRaEngine/LoraKeysManagerFacade/FunctionsBundler/DeduplicationExecutionItem.cs @@ -122,7 +122,7 @@ internal async Task GetDuplicateMessageResultAsync(DevEui de { if (await this.edgeDeviceGetter.IsEdgeDeviceAsync(previousGateway, cts.Token)) { - var res = await this.serviceClient.InvokeDeviceMethodAsync(previousGateway, LoraKeysManagerFacadeConstants.NetworkServerModuleId, method); + var res = await this.serviceClient.InvokeDeviceMethodAsync(previousGateway, LoraKeysManagerFacadeConstants.NetworkServerModuleId, method, default); logger?.LogDebug("Connection owner changed and direct method was called on previous gateway '{PreviousConnectionOwner}' to close connection; result is '{Status}'", previousGateway, res?.Status); if (!HttpUtilities.IsSuccessStatusCode(res.Status)) diff --git a/LoRaEngine/LoraKeysManagerFacade/IEdgeDeviceGetter.cs b/LoRaEngine/LoraKeysManagerFacade/IEdgeDeviceGetter.cs index 5607c48ecc..d96d87ba9b 100644 --- a/LoRaEngine/LoraKeysManagerFacade/IEdgeDeviceGetter.cs +++ b/LoRaEngine/LoraKeysManagerFacade/IEdgeDeviceGetter.cs @@ -3,11 +3,13 @@ namespace LoraKeysManagerFacade { + using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; public interface IEdgeDeviceGetter { Task IsEdgeDeviceAsync(string lnsId, CancellationToken cancellationToken); + Task> ListEdgeDevicesAsync(CancellationToken cancellationToken); } } diff --git a/LoRaEngine/LoraKeysManagerFacade/IServiceClient.cs b/LoRaEngine/LoraKeysManagerFacade/IServiceClient.cs index 7899971ba9..eacbb6eff5 100644 --- a/LoRaEngine/LoraKeysManagerFacade/IServiceClient.cs +++ b/LoRaEngine/LoraKeysManagerFacade/IServiceClient.cs @@ -3,6 +3,7 @@ namespace LoraKeysManagerFacade { + using System.Threading; using System.Threading.Tasks; using Microsoft.Azure.Devices; @@ -11,7 +12,7 @@ namespace LoraKeysManagerFacade /// public interface IServiceClient { - Task InvokeDeviceMethodAsync(string deviceId, string moduleId, CloudToDeviceMethod cloudToDeviceMethod); + Task InvokeDeviceMethodAsync(string deviceId, string moduleId, CloudToDeviceMethod cloudToDeviceMethod, CancellationToken cancellationToken); Task SendAsync(string deviceId, Message message); } diff --git a/LoRaEngine/LoraKeysManagerFacade/LoraKeysManagerFacadeConstants.cs b/LoRaEngine/LoraKeysManagerFacade/LoraKeysManagerFacadeConstants.cs index 358e16bdfc..fa7565e387 100644 --- a/LoRaEngine/LoraKeysManagerFacade/LoraKeysManagerFacadeConstants.cs +++ b/LoRaEngine/LoraKeysManagerFacade/LoraKeysManagerFacadeConstants.cs @@ -11,6 +11,7 @@ internal static class LoraKeysManagerFacadeConstants internal const string TwinProperty_DevAddr = "DevAddr"; internal const string TwinProperty_NwkSKey = "NwkSKey"; internal const string NetworkServerModuleId = "LoRaWanNetworkSrvModule"; + internal const string ClearCacheMethodName = "clearcache"; internal const string CloudToDeviceMessageMethodName = "cloudtodevicemessage"; internal const string CloudToDeviceCloseConnection = "closeconnection"; public const string RoundTripDateTimeStringFormat = "o"; diff --git a/LoRaEngine/LoraKeysManagerFacade/SendCloudToDeviceMessage/SendCloudToDeviceMessage.cs b/LoRaEngine/LoraKeysManagerFacade/SendCloudToDeviceMessage/SendCloudToDeviceMessage.cs index 5ecbca748d..54f904a9b9 100644 --- a/LoRaEngine/LoraKeysManagerFacade/SendCloudToDeviceMessage/SendCloudToDeviceMessage.cs +++ b/LoRaEngine/LoraKeysManagerFacade/SendCloudToDeviceMessage/SendCloudToDeviceMessage.cs @@ -214,7 +214,7 @@ private async Task SendMessageViaDirectMethodOrPubSubAsync( if (await edgeDeviceGetter.IsEdgeDeviceAsync(preferredGatewayID, cancellationToken)) { - var res = await this.serviceClient.InvokeDeviceMethodAsync(preferredGatewayID, LoraKeysManagerFacadeConstants.NetworkServerModuleId, method); + var res = await this.serviceClient.InvokeDeviceMethodAsync(preferredGatewayID, LoraKeysManagerFacadeConstants.NetworkServerModuleId, method, cancellationToken); if (HttpUtilities.IsSuccessStatusCode(res.Status)) { this.log.LogInformation("Direct method call to {gatewayID} and {devEUI} succeeded with {statusCode}", preferredGatewayID, devEUI, res.Status); diff --git a/LoRaEngine/LoraKeysManagerFacade/ServiceClientAdapter.cs b/LoRaEngine/LoraKeysManagerFacade/ServiceClientAdapter.cs index afc086e350..9e3da32d2c 100644 --- a/LoRaEngine/LoraKeysManagerFacade/ServiceClientAdapter.cs +++ b/LoRaEngine/LoraKeysManagerFacade/ServiceClientAdapter.cs @@ -3,6 +3,7 @@ namespace LoraKeysManagerFacade { + using System.Threading; using System.Threading.Tasks; using Microsoft.Azure.Devices; @@ -15,7 +16,10 @@ public ServiceClientAdapter(ServiceClient serviceClient) this.serviceClient = serviceClient ?? throw new System.ArgumentNullException(nameof(serviceClient)); } - public Task InvokeDeviceMethodAsync(string deviceId, string moduleId, CloudToDeviceMethod cloudToDeviceMethod) => this.serviceClient.InvokeDeviceMethodAsync(deviceId, moduleId, cloudToDeviceMethod); + public Task InvokeDeviceMethodAsync(string deviceId, + string moduleId, + CloudToDeviceMethod cloudToDeviceMethod, + CancellationToken cancellationToken) => this.serviceClient.InvokeDeviceMethodAsync(deviceId, moduleId, cloudToDeviceMethod, cancellationToken); public Task SendAsync(string deviceId, Message message) => this.serviceClient.SendAsync(deviceId, message); } diff --git a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/CloudControlHost.cs b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/CloudControlHost.cs index 2a5978ad1d..523ecf2a5e 100644 --- a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/CloudControlHost.cs +++ b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/CloudControlHost.cs @@ -5,6 +5,7 @@ namespace LoRaWan.NetworkServer { + using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Hosting; @@ -13,7 +14,7 @@ internal class CloudControlHost : IHostedService { private readonly ILnsRemoteCallListener lnsRemoteCallListener; private readonly ILnsRemoteCallHandler lnsRemoteCallHandler; - private readonly string gatewayId; + private readonly string[] subscriptionChannels; public CloudControlHost(ILnsRemoteCallListener lnsRemoteCallListener, ILnsRemoteCallHandler lnsRemoteCallHandler, @@ -21,15 +22,16 @@ public CloudControlHost(ILnsRemoteCallListener lnsRemoteCallListener, { this.lnsRemoteCallListener = lnsRemoteCallListener; this.lnsRemoteCallHandler = lnsRemoteCallHandler; - this.gatewayId = networkServerConfiguration.GatewayID; + this.subscriptionChannels = new string[] { networkServerConfiguration.GatewayID, Constants.CloudToDeviceClearCache }; } + public Task StartAsync(CancellationToken cancellationToken) => - this.lnsRemoteCallListener.SubscribeAsync(this.gatewayId, - remoteCall => this.lnsRemoteCallHandler.ExecuteAsync(remoteCall, cancellationToken), - cancellationToken); + Task.WhenAll(this.subscriptionChannels.Select(c => this.lnsRemoteCallListener.SubscribeAsync(c, + remoteCall => this.lnsRemoteCallHandler.ExecuteAsync(remoteCall, cancellationToken), + cancellationToken))); public Task StopAsync(CancellationToken cancellationToken) => - this.lnsRemoteCallListener.UnsubscribeAsync(this.gatewayId, cancellationToken); + Task.WhenAll(this.subscriptionChannels.Select(c => this.lnsRemoteCallListener.UnsubscribeAsync(c, cancellationToken))); } } diff --git a/Tests/Integration/DeduplicationWithRedisIntegrationTests.cs b/Tests/Integration/DeduplicationWithRedisIntegrationTests.cs index 46f6923594..70c528f1b4 100644 --- a/Tests/Integration/DeduplicationWithRedisIntegrationTests.cs +++ b/Tests/Integration/DeduplicationWithRedisIntegrationTests.cs @@ -59,7 +59,7 @@ public async Task When_Called_Multiple_Times_With_Same_Device_Should_Detect_Dupl { this.edgeDeviceGetter.Setup(m => m.IsEdgeDeviceAsync(It.IsAny(), It.IsAny())).ReturnsAsync(isEdgeDevice); this.serviceClientMock.Setup( - x => x.InvokeDeviceMethodAsync(It.IsAny(), LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.IsAny())) + x => x.InvokeDeviceMethodAsync(It.IsAny(), LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.IsAny(), It.IsAny())) .ReturnsAsync(new CloudToDeviceMethodResult() { Status = 200 }); var devEUI = TestEui.GenerateDevEui(); @@ -101,7 +101,7 @@ public async Task When_Called_Multiple_Times_With_Same_Device_Should_Detect_Dupl // gateway1 should be notified that it needs to drop connection for the device this.serviceClientMock.Verify(x => x.InvokeDeviceMethodAsync(gateway1, LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.Is(m => m.MethodName == LoraKeysManagerFacadeConstants.CloudToDeviceCloseConnection - && m.GetPayloadAsJson().Contains(devEUI.ToString())))); + && m.GetPayloadAsJson().Contains(devEUI.ToString())), It.IsAny())); } else { diff --git a/Tests/Unit/LoRaTools/ApiVersionTest.cs b/Tests/Unit/LoRaTools/ApiVersionTest.cs index abd3e67cf9..63064ae2f4 100644 --- a/Tests/Unit/LoRaTools/ApiVersionTest.cs +++ b/Tests/Unit/LoRaTools/ApiVersionTest.cs @@ -32,7 +32,8 @@ public async Task LatestVersion_Returns_Bad_Request_If_InvalidVersion_Requested( (req) => new DeviceGetter(null, null, NullLogger.Instance).GetDevice(req), (req) => Task.Run(() => new FCntCacheCheck(null, NullLogger.Instance).NextFCntDownInvoke(req)), (req) => Task.Run(() => new FunctionBundlerFunction(Array.Empty(), NullLogger.Instance).FunctionBundler(req, string.Empty)), - (req) => new SendCloudToDeviceMessage(null, null, null, null, null, null).Run(req, string.Empty, default) + (req) => new SendCloudToDeviceMessage(null, null, null, null, null, null).Run(req, string.Empty, default), + (req) => new ClearLnsCache(null, null, null, NullLogger.Instance).ClearNetworkServerCache(req, default) }; foreach (var apiCall in apiCalls) diff --git a/Tests/Unit/LoraKeysManagerFacade/ClearLnsCacheTest.cs b/Tests/Unit/LoraKeysManagerFacade/ClearLnsCacheTest.cs new file mode 100644 index 0000000000..c627cf1444 --- /dev/null +++ b/Tests/Unit/LoraKeysManagerFacade/ClearLnsCacheTest.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#nullable enable + +namespace LoRaWan.Tests.Unit.LoraKeysManagerFacade +{ + using System; + using System.Collections.Generic; + using System.Threading; + using System.Threading.Tasks; + using global::LoraKeysManagerFacade; + using global::LoRaTools; + using Microsoft.Azure.Devices; + using Microsoft.Azure.Devices.Common.Exceptions; + using Microsoft.Extensions.Logging.Abstractions; + using Moq; + using Xunit; + + public class ClearLnsCacheTest + { + private readonly Mock edgeDeviceGetter; + private readonly Mock serviceClient; + private readonly Mock channelPublisher; + private readonly ClearLnsCache clearLnsCache; + + public ClearLnsCacheTest() + { + this.edgeDeviceGetter = new Mock(); + this.serviceClient = new Mock(); + this.channelPublisher = new Mock(); + this.clearLnsCache = new ClearLnsCache(this.edgeDeviceGetter.Object, this.serviceClient.Object, this.channelPublisher.Object, NullLogger.Instance); + } + + [Fact] + public async Task ClearLnsCacheInternalAsync_Invokes_Both_Edge_And_Non_Edge_Devices() + { + //arrange + var listEdgeDevices = new List { "edge1", "edge2" }; + this.edgeDeviceGetter.Setup(m => m.ListEdgeDevicesAsync(It.IsAny())).ReturnsAsync(listEdgeDevices); + + this.serviceClient.Setup(m => m.InvokeDeviceMethodAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new CloudToDeviceMethodResult() { Status = 200 }); + + //act + await this.clearLnsCache.ClearLnsCacheInternalAsync(default); + + //assert + foreach (var edgeDevice in listEdgeDevices) + { + this.serviceClient.Verify(c => c.InvokeDeviceMethodAsync(edgeDevice, + LoraKeysManagerFacadeConstants.NetworkServerModuleId, + It.Is(c => c.MethodName == LoraKeysManagerFacadeConstants.ClearCacheMethodName), + It.IsAny()), Times.Once()); + } + + this.channelPublisher.Verify(c => c.PublishAsync(LoraKeysManagerFacadeConstants.ClearCacheMethodName, It.Is(r => r.Kind == RemoteCallKind.ClearCache)), Times.Once()); + } + + [Fact] + public async Task ClearLnsCacheInternalAsync_Invokes_Only_Pub_Sub_When_No_Edge_Devices() + { + //arrange + this.edgeDeviceGetter.Setup(m => m.ListEdgeDevicesAsync(It.IsAny())).ReturnsAsync(Array.Empty()); + + this.serviceClient.Setup(m => m.InvokeDeviceMethodAsync(It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new CloudToDeviceMethodResult() { Status = 200 }); + + //act + await this.clearLnsCache.ClearLnsCacheInternalAsync(default); + + //assert + this.serviceClient.VerifyNoOtherCalls(); + + this.channelPublisher.Verify(c => c.PublishAsync(LoraKeysManagerFacadeConstants.ClearCacheMethodName, + It.Is(r => r.Kind == RemoteCallKind.ClearCache)), Times.Once()); + } + } +} diff --git a/Tests/Unit/LoraKeysManagerFacade/EdgeDeviceGetterTests.cs b/Tests/Unit/LoraKeysManagerFacade/EdgeDeviceGetterTests.cs index 176206eb56..d937d1e43b 100644 --- a/Tests/Unit/LoraKeysManagerFacade/EdgeDeviceGetterTests.cs +++ b/Tests/Unit/LoraKeysManagerFacade/EdgeDeviceGetterTests.cs @@ -3,6 +3,7 @@ namespace LoRaWan.Tests.Unit.LoraKeysManagerFacade { + using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -46,6 +47,36 @@ public async Task IsEdgeDeviceAsync_Should_Not_Reach_IoTHub_Twice_If_Invoked_In_ _ = this.mockQuery.Invocations.Single(x => x.Method.Name.Equals(nameof(IQuery.GetNextAsTwinAsync), System.StringComparison.OrdinalIgnoreCase)); } + [Fact] + public async Task ListEdgeDevicesAsync_Returns_Expected_Device_List() + { + var edgeDeviceGetter = new EdgeDeviceGetter(InitRegistryManager(), new LoRaInMemoryDeviceStore(), NullLogger.Instance); + + var list = await edgeDeviceGetter.ListEdgeDevicesAsync(default); + + Assert.Contains(EdgeDevice1, list); + } + + [Fact] + public async Task ListEdgeDevicesAsync_Returns_Empty_Device_List() + { + this.mockQuery = new Mock(); + this.mockRegistryManager = new Mock(); + + mockQuery.Setup(x => x.GetNextAsTwinAsync()) + .ReturnsAsync(Array.Empty()); + + mockRegistryManager + .Setup(x => x.CreateQuery(It.IsAny())) + .Returns(mockQuery.Object); + + var edgeDeviceGetter = new EdgeDeviceGetter(this.mockRegistryManager.Object, new LoRaInMemoryDeviceStore(), NullLogger.Instance); + + var list = await edgeDeviceGetter.ListEdgeDevicesAsync(default); + + Assert.Empty(list); + } + private IDeviceRegistryManager InitRegistryManager() { this.mockQuery = new Mock(); diff --git a/Tests/Unit/LoraKeysManagerFacade/MessageDeduplicationTests.cs b/Tests/Unit/LoraKeysManagerFacade/MessageDeduplicationTests.cs index 72fc1260f2..1251ff98c9 100644 --- a/Tests/Unit/LoraKeysManagerFacade/MessageDeduplicationTests.cs +++ b/Tests/Unit/LoraKeysManagerFacade/MessageDeduplicationTests.cs @@ -77,7 +77,7 @@ public async Task MessageDeduplication_DifferentDevices_Allowed() var dev2EUI = TestEui.GenerateDevEui(); this.serviceClientMock.Setup(x => x.InvokeDeviceMethodAsync( - It.IsAny(), LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.IsAny())) + It.IsAny(), LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.IsAny(), It.IsAny())) .ReturnsAsync(new CloudToDeviceMethodResult() { Status = 200 }); var result = await this.deduplicationExecutionItem.GetDuplicateMessageResultAsync(dev1EUI, gateway1Id, 1, 1); @@ -94,7 +94,7 @@ public async Task MessageDeduplication_DifferentDevices_Allowed() x => x.InvokeDeviceMethodAsync(gateway1Id.ToString(), LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.Is( m => m.MethodName == LoraKeysManagerFacadeConstants.CloudToDeviceCloseConnection - && m.GetPayloadAsJson().Contains(dev1EUI.ToString()))), + && m.GetPayloadAsJson().Contains(dev1EUI.ToString())), It.IsAny()), Times.Once); result = await this.deduplicationExecutionItem.GetDuplicateMessageResultAsync(dev2EUI, gateway1Id, 1, 1); @@ -110,7 +110,7 @@ public async Task MessageDeduplication_DifferentDevices_Allowed() x => x.InvokeDeviceMethodAsync(gateway1Id.ToString(), LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.Is( m => m.MethodName == LoraKeysManagerFacadeConstants.CloudToDeviceCloseConnection - && m.GetPayloadAsJson().Contains(dev2EUI.ToString()))), + && m.GetPayloadAsJson().Contains(dev2EUI.ToString())), It.IsAny()), Times.Once); } @@ -122,7 +122,7 @@ public async Task MessageDeduplication_When_Direct_Method_Throws_Does_Not_Throw( var dev1EUI = TestEui.GenerateDevEui(); this.serviceClientMock.Setup(x => x.InvokeDeviceMethodAsync( - It.IsAny(), It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new IotHubException("Failed to invoke direct method")); var result = await this.deduplicationExecutionItem.GetDuplicateMessageResultAsync(dev1EUI, gateway1Id, 1, 1); @@ -133,7 +133,7 @@ public async Task MessageDeduplication_When_Direct_Method_Throws_Does_Not_Throw( this.serviceClientMock.Verify( x => x.InvokeDeviceMethodAsync(gateway1Id.ToString(), LoraKeysManagerFacadeConstants.NetworkServerModuleId, - It.Is(m => m.MethodName == LoraKeysManagerFacadeConstants.CloudToDeviceCloseConnection)), + It.Is(m => m.MethodName == LoraKeysManagerFacadeConstants.CloudToDeviceCloseConnection), It.IsAny()), Times.Once); Assert.False(result.IsDuplicate); diff --git a/Tests/Unit/LoraKeysManagerFacade/SendCloudToDeviceMessageTest.cs b/Tests/Unit/LoraKeysManagerFacade/SendCloudToDeviceMessageTest.cs index bb93557aee..23d6171fad 100644 --- a/Tests/Unit/LoraKeysManagerFacade/SendCloudToDeviceMessageTest.cs +++ b/Tests/Unit/LoraKeysManagerFacade/SendCloudToDeviceMessageTest.cs @@ -139,8 +139,8 @@ public async Task When_Device_Is_Found_In_Cache_Should_Send_Via_Direct_Method_Or if (isEdgeDevice) { - this.serviceClient.Setup(x => x.InvokeDeviceMethodAsync("gateway1", LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.IsNotNull())) - .Callback((device, methodName, method) => receivedC2DMessage = JsonConvert.DeserializeObject(method.GetPayloadAsJson())) + this.serviceClient.Setup(x => x.InvokeDeviceMethodAsync("gateway1", LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.IsNotNull(), default)) + .Callback((device, methodName, method, _) => receivedC2DMessage = JsonConvert.DeserializeObject(method.GetPayloadAsJson())) .ReturnsAsync(new CloudToDeviceMethodResult() { Status = (int)HttpStatusCode.OK }); } else @@ -176,7 +176,7 @@ public async Task When_Direct_Method_Returns_Error_Code_Should_Forward_Status_Er var preferredGateway = new LoRaDevicePreferredGateway("gateway1", 100); LoRaDevicePreferredGateway.SaveToCache(this.cacheStore, devEui, preferredGateway); - this.serviceClient.Setup(x => x.InvokeDeviceMethodAsync("gateway1", LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.IsNotNull())) + this.serviceClient.Setup(x => x.InvokeDeviceMethodAsync("gateway1", LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.IsNotNull(), default)) .ReturnsAsync(new CloudToDeviceMethodResult() { Status = (int)HttpStatusCode.BadRequest }); var actual = await this.sendCloudToDeviceMessage.SendCloudToDeviceMessageImplementationAsync( @@ -202,7 +202,7 @@ public async Task When_Direct_Method_Throws_Exception_Should_Return_Application_ var preferredGateway = new LoRaDevicePreferredGateway("gateway1", 100); LoRaDevicePreferredGateway.SaveToCache(this.cacheStore, devEui, preferredGateway); - this.serviceClient.Setup(x => x.InvokeDeviceMethodAsync("gateway1", LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.IsNotNull())) + this.serviceClient.Setup(x => x.InvokeDeviceMethodAsync("gateway1", LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.IsNotNull(), default)) .ThrowsAsync(new IotHubCommunicationException(string.Empty)); var actual = await this.sendCloudToDeviceMessage.SendCloudToDeviceMessageImplementationAsync( @@ -338,8 +338,8 @@ public async Task When_Querying_Devices_And_Finds_Class_C_Should_Update_Cache_An if (isEdgeDevice) { - this.serviceClient.Setup(x => x.InvokeDeviceMethodAsync("gateway1", LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.IsNotNull())) - .Callback((device, methodName, method) => receivedC2DMessage = JsonConvert.DeserializeObject(method.GetPayloadAsJson())) + this.serviceClient.Setup(x => x.InvokeDeviceMethodAsync("gateway1", LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.IsNotNull(), default)) + .Callback((device, methodName, method, _) => receivedC2DMessage = JsonConvert.DeserializeObject(method.GetPayloadAsJson())) .ReturnsAsync(new CloudToDeviceMethodResult() { Status = (int)HttpStatusCode.OK }); } else @@ -406,8 +406,8 @@ public async Task When_Querying_Devices_And_Finds_Single_Gateway_Class_C_Should_ LoRaCloudToDeviceMessage receivedC2DMessage = null; if (isEdgeDevice) { - this.serviceClient.Setup(x => x.InvokeDeviceMethodAsync("mygateway", LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.IsNotNull())) - .Callback((device, methodName, method) => receivedC2DMessage = JsonConvert.DeserializeObject(method.GetPayloadAsJson())) + this.serviceClient.Setup(x => x.InvokeDeviceMethodAsync("mygateway", LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.IsNotNull(), default)) + .Callback((device, methodName, method, _) => receivedC2DMessage = JsonConvert.DeserializeObject(method.GetPayloadAsJson())) .ReturnsAsync(new CloudToDeviceMethodResult() { Status = (int)HttpStatusCode.OK }); } else From 0e72e71da7e37769f0db2767b28a9b7204759d7f Mon Sep 17 00:00:00 2001 From: danigian <1955514+danigian@users.noreply.github.com> Date: Wed, 29 Jun 2022 14:37:39 +0200 Subject: [PATCH 15/22] Reverting changes related to CI and ocw-edge/dev branch --- .github/workflows/ci.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3a70e8c304..f5332e8ffd 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -4,7 +4,6 @@ on: # rebuild any PRs and main branch changes branches: - master - dev - - ocw-edge/dev paths-ignore: - 'Docs/**' - 'Arduino/**' From 924769abab09878cee128a47d51ace6aacb6dd8a Mon Sep 17 00:00:00 2001 From: Daniele Antonio Maggio <1955514+danigian@users.noreply.github.com> Date: Thu, 30 Jun 2022 20:24:53 +0200 Subject: [PATCH 16/22] Bringing E2E tests to ocw-edge/dev branch (#1750) --- .github/workflows/e2e-ci.yaml | 135 +++++++++++++++- AssemblyInfo.cs | 1 + Tests/{E2E => Common}/LoRaAPIHelper.cs | 2 +- Tests/Simulation/IntegrationTestFixtureSim.cs | 23 +++ Tests/Simulation/SimulatedCloudTests.cs | 125 +++++++++++++++ Tests/Simulation/SimulatedLoadTests.cs | 149 ++++-------------- Tests/Simulation/SimulationUtils.cs | 128 +++++++++++++++ Tests/Simulation/appsettings.json | 5 +- 8 files changed, 444 insertions(+), 124 deletions(-) rename Tests/{E2E => Common}/LoRaAPIHelper.cs (98%) create mode 100644 Tests/Simulation/SimulatedCloudTests.cs create mode 100644 Tests/Simulation/SimulationUtils.cs diff --git a/.github/workflows/e2e-ci.yaml b/.github/workflows/e2e-ci.yaml index 1e17ed0227..0da4ae7f0a 100644 --- a/.github/workflows/e2e-ci.yaml +++ b/.github/workflows/e2e-ci.yaml @@ -14,7 +14,7 @@ on: # rebuild any PRs and main branch changes description: 'Include Load Tests in current run' default: 'false' TestsToRun: - default: '[SensorDecodingTest,OTAAJoinTest,ABPTest,OTAATest,MacTest,ClassCTest,C2DMessageTest,MultiGatewayTest,MultiConcentratorTest,CupsTest,LnsDiscoveryTest]' + default: '[SensorDecodingTest,OTAAJoinTest,ABPTest,OTAATest,MacTest,ClassCTest,C2DMessageTest,MultiGatewayTest,MultiConcentratorTest,CupsTest,LnsDiscoveryTest,CloudDeploymentTest]' description: 'tests to run' TxPower: description: 'TXPower value to use in E2E tests' @@ -58,10 +58,10 @@ jobs: echo "::set-output name=E2ETestsToRun::${{ github.event.inputs.TestsToRun }}" elif [ ${{ github.event_name }} == 'pull_request' ]; then echo "Set up for pull request" - echo "::set-output name=E2ETestsToRun::[SensorDecodingTest,OTAAJoinTest,ABPTest,OTAATest,MacTest,ClassCTest,C2DMessageTest,MultiGatewayTest,MultiConcentratorTest,CupsTest,LnsDiscoveryTest]" + echo "::set-output name=E2ETestsToRun::[SensorDecodingTest,OTAAJoinTest,ABPTest,OTAATest,MacTest,ClassCTest,C2DMessageTest,MultiGatewayTest,MultiConcentratorTest,CupsTest,LnsDiscoveryTest,CloudDeploymentTest]" else echo "Set up for cron trigger" - echo "::set-output name=E2ETestsToRun::[SensorDecodingTest,OTAAJoinTest,ABPTest,OTAATest,MacTest,ClassCTest,C2DMessageTest,MultiGatewayTest,MultiConcentratorTest,CupsTest,LnsDiscoveryTest]" + echo "::set-output name=E2ETestsToRun::[SensorDecodingTest,OTAAJoinTest,ABPTest,OTAATest,MacTest,ClassCTest,C2DMessageTest,MultiGatewayTest,MultiConcentratorTest,CupsTest,LnsDiscoveryTest,CloudDeploymentTest]" fi - id: check-if-run @@ -170,7 +170,8 @@ jobs: with: fetch-depth: '2' - - run: | + - id: image_tag_definition + run: | if [ ${{ github.ref }} = "refs/heads/dev" ]; then echo "dev" IMAGE_TAG="$DEV_IMAGE_TAG" @@ -189,6 +190,7 @@ jobs: echo "Using image tag $IMAGE_TAG" echo "::set-env name=NET_SRV_VERSION::$IMAGE_TAG" echo "::set-env name=LBS_VERSION::$IMAGE_TAG" + echo "::set-output name=imagetag::$IMAGE_TAG" env: ACTIONS_ALLOW_UNSECURE_COMMANDS: true @@ -216,6 +218,9 @@ jobs: env: CONTAINER_REGISTRY_ADDRESS: ${{ env.CONTAINER_REGISTRY_ADDRESS }} + outputs: + imagetag: ${{ steps.image_tag_definition.outputs.imagetag }} + # Generate root and server certificates and copy required files to RPi certificates_job : timeout-minutes: 5 @@ -287,6 +292,36 @@ jobs: clientfwdigest: ${{ steps.generate_step.outputs.clientfwdigest }} clientfwversion: ${{ steps.generate_step.outputs.clientfwversion }} + # Deploy Cloud based LoRaWAN Network Server + deploy_cloud_lns: + needs: + - env_var + - build_push_docker_images + - certificates_job + runs-on: ubuntu-latest + if: needs.env_var.outputs.RunE2ETestsOnly != 'true' && needs.env_var.outputs.StopFullCi != 'true' + name: Deploy Cloud based LNS + steps: + - name: "Deploy container instance" + id: "deploycloudlns" + shell: bash + run: | + az login --service-principal -u ${{ secrets.AZURE_SP_CLIENTID }} -p ${{ secrets.AZURE_SP_SECRET }} --tenant ${{ secrets.AZURE_TENANTID }} + az container create --resource-group ${{ secrets.AZURE_RG }} --ip-address Private --location westeurope --name cloudlns \ + --environment-variables LOG_TO_TCP_ADDRESS=${{ needs.certificates_job.outputs.itestupip }} LOG_TO_TCP_PORT=6100 LOG_TO_TCP=true LOG_LEVEL=1 IOTHUBHOSTNAME=${{secrets.IOTHUB_HOSTNAME}} ENABLE_GATEWAY=false CLOUD_DEPLOYMENT=true \ + --image ${{ env.CONTAINER_REGISTRY_ADDRESS }}/lorawannetworksrvmodule:${{needs.build_push_docker_images.outputs.imagetag}}-amd64 \ + --ports 5000 \ + --protocol TCP \ + --registry-username ${{ env.CONTAINER_REGISTRY_USERNAME }} \ + --registry-password ${{ env.CONTAINER_REGISTRY_PASSWORD }} \ + --restart-policy Never \ + --secure-environment-variables FACADE_AUTH_CODE=${{ secrets.FUNCTION_FACADE_AUTH_CODE }} FACADE_SERVER_URL=${{ secrets.FUNCTION_FACADE_SERVER_URL }} REDIS_CONNECTION_STRING=${{ secrets.REDIS_HOSTNAME }}.redis.cache.windows.net:6380,password=${{ secrets.REDIS_PASSWORD }},ssl=True,abortConnect=False \ + --subnet ${{ secrets.AZURE_SUBNET_NAME }} \ + --vnet ${{ secrets.AZURE_VNET_NAME }} --output none + echo "::set-output name=cloudlnsprivateip::$(az container show --name cloudlns --resource-group ${{ secrets.AZURE_RG }} --query ipAddress.ip -o tsv)" + outputs: + cloudlnsprivateip: ${{ steps.deploycloudlns.outputs.cloudlnsprivateip }} + # Deploy IoT Edge solution to ARM gateway deploy_arm_gw_iot_edge: timeout-minutes: 20 @@ -433,7 +468,9 @@ jobs: INTEGRATIONTEST_LoadTestLnsEndpoints: ${{ secrets.LOAD_TEST_LNS_ENDPOINTS }} INTEGRATIONTEST_NumberOfLoadTestDevices: 10 INTEGRATIONTEST_NumberOfLoadTestConcentrators: 4 - + INTEGRATIONTEST_FunctionAppCode: ${{ secrets.FUNCTION_FACADE_AUTH_CODE }} + INTEGRATIONTEST_FunctionAppBaseUrl: ${{ secrets.FUNCTION_FACADE_SERVER_URL }} + INTEGRATIONTEST_TcpLogPort: 6000 steps: - uses: actions/checkout@v2 name: Checkout current branch @@ -460,7 +497,7 @@ jobs: shell: bash run: | dotnet test --logger trx --no-build --configuration ${{ env.BUILD_CONFIGURATION }} \ - -r ${{ env.TESTS_RESULTS_FOLDER }}/LoadTest/ \ + -r ${{ env.TESTS_RESULTS_FOLDER }}/LoadTest/ --filter "SimulatedLoadTests" \ ${{ env.TESTS_FOLDER }}/Simulation/LoRaWan.Tests.Simulation.csproj # Upload test results as artifact @@ -470,6 +507,77 @@ jobs: name: load-test-results path: ${{ env.TESTS_RESULTS_FOLDER }}/LoadTest/ + cloud_test_job: + timeout-minutes: 150 + name: Run cloud only deployment Tests + environment: + name: CI_AZURE_ENVIRONMENT + url: ${{ needs.env_var.outputs.CheckSuiteUrl }} + if: always() && needs.deploy_cloud_lns.result == 'success' && needs.deploy_facade_function.result == 'success' && needs.env_var.outputs.StopFullCi != 'true' && contains(needs.env_var.outputs.E2ETestsToRun, 'CloudDeploymentTest') && !(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'CloudDeploymentTest')) + needs: + - deploy_facade_function + - deploy_cloud_lns + - env_var + - certificates_job + runs-on: [ self-hosted, x64 ] + env: + INTEGRATIONTEST_IoTHubEventHubConnectionString: ${{ secrets.IOTHUB_EVENT_HUB_CONNECTION_STRING }} + INTEGRATIONTEST_IoTHubEventHubConsumerGroup: ${{ secrets.IOTHUB_CI_CONSUMER_GROUP }} + INTEGRATIONTEST_IoTHubConnectionString: ${{ secrets.IOTHUB_OWNER_CONNECTION_STRING }} + INTEGRATIONTEST_LeafDeviceGatewayID: itestarm1 + INTEGRATIONTEST_DevicePrefix: '12' + INTEGRATIONTEST_RunningInCI: true + INTEGRATIONTEST_LoadTestLnsEndpoints: "{ \\\"1\\\": \\\"ws://${{ needs.deploy_cloud_lns.outputs.cloudlnsprivateip }}:5000\\\" }" + INTEGRATIONTEST_NumberOfLoadTestDevices: 1 + INTEGRATIONTEST_NumberOfLoadTestConcentrators: 2 + INTEGRATIONTEST_FunctionAppCode: ${{ secrets.FUNCTION_FACADE_AUTH_CODE }} + INTEGRATIONTEST_FunctionAppBaseUrl: ${{ secrets.FUNCTION_FACADE_SERVER_URL }} + INTEGRATIONTEST_TcpLogPort: 6100 + + steps: + - uses: actions/checkout@v2 + name: Checkout current branch + + - name: Setup .NET + uses: actions/setup-dotnet@v1.8.2 + with: + dotnet-version: '6.0.x' # SDK Version to use. + + - name: .NET SDK Information + run: + dotnet --info + + - name: Configuration for simulated cloud tests + uses: cschleiden/replace-tokens@v1 + with: + files: '${{ env.TESTS_FOLDER }}/Simulation/appsettings.json' + + - name: Build simulated cloud tests + run: | + dotnet build --configuration ${{ env.BUILD_CONFIGURATION }} ${{ env.TESTS_FOLDER }}/Simulation/LoRaWan.Tests.Simulation.csproj + + - name: Runs simulated cloud tests + shell: bash + run: | + dotnet test --logger trx --no-build --configuration ${{ env.BUILD_CONFIGURATION }} \ + -r ${{ env.TESTS_RESULTS_FOLDER }}/LoadTest/ --filter "SimulatedCloudTests" \ + ${{ env.TESTS_FOLDER }}/Simulation/LoRaWan.Tests.Simulation.csproj + + - name: Add CloudDeploymentTest Test Label + uses: buildsville/add-remove-label@v1 + if: github.event_name == 'pull_request' && success() + with: + token: ${{ github.token }} + label: 'CloudDeploymentTest' + type: add + + # Upload simulated cloud results as artifact + - uses: actions/upload-artifact@v1 + if: always() + with: + name: simulated-cloud-test-results + path: ${{ env.TESTS_RESULTS_FOLDER }}/LoadTest/ + # Runs E2E tests in dedicated agent, while having modules deployed into PI (arm32v7) e2e_tests_job: timeout-minutes: 150 @@ -647,3 +755,18 @@ jobs: AZURE_SP_CLIENTID: ${{ secrets.AZURE_SP_CLIENTID }} AZURE_SP_SECRET: ${{ secrets.AZURE_SP_SECRET }} AZURE_TENANTID: ${{ secrets.AZURE_TENANTID }} + + delete_cloud_lns: + name: Delete cloud LNS + if: always() + runs-on: ubuntu-latest + needs: + - e2e_tests_job + steps: + - uses: actions/checkout@v2 + - name: "Delete azure container instance for cloudlns" + shell: bash + run: | + az login --service-principal -u ${{ secrets.AZURE_SP_CLIENTID }} -p ${{ secrets.AZURE_SP_SECRET }} --tenant ${{ secrets.AZURE_TENANTID }} + az container logs -g ${{ secrets.AZURE_RG }} --name cloudlns + az container delete --yes --resource-group ${{ secrets.AZURE_RG }} --name cloudlns --output none diff --git a/AssemblyInfo.cs b/AssemblyInfo.cs index 4de6dfcd02..124162d932 100644 --- a/AssemblyInfo.cs +++ b/AssemblyInfo.cs @@ -11,4 +11,5 @@ [assembly: InternalsVisibleTo("LoRaWan.Tests.Integration")] [assembly: InternalsVisibleTo("LoRaWan.Tests.E2E")] [assembly: InternalsVisibleTo("LoRaWan.Tests.Common")] +[assembly: InternalsVisibleTo("LoRaWan.Tests.Simulation")] [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/Tests/E2E/LoRaAPIHelper.cs b/Tests/Common/LoRaAPIHelper.cs similarity index 98% rename from Tests/E2E/LoRaAPIHelper.cs rename to Tests/Common/LoRaAPIHelper.cs index a70f2b8f47..53df906559 100644 --- a/Tests/E2E/LoRaAPIHelper.cs +++ b/Tests/Common/LoRaAPIHelper.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -namespace LoRaWan.Tests.E2E +namespace LoRaWan.Tests.Common { using System; using System.Net.Http; diff --git a/Tests/Simulation/IntegrationTestFixtureSim.cs b/Tests/Simulation/IntegrationTestFixtureSim.cs index 3c96f03e8d..8db6a84272 100644 --- a/Tests/Simulation/IntegrationTestFixtureSim.cs +++ b/Tests/Simulation/IntegrationTestFixtureSim.cs @@ -8,6 +8,7 @@ namespace LoRaWan.Tests.Simulation using System.Globalization; using System.IO; using System.Linq; + using System.Threading.Tasks; using LoRaWan.NetworkServer; using LoRaWan.Tests.Common; using Newtonsoft.Json.Linq; @@ -23,6 +24,9 @@ public class IntegrationTestFixtureSim : IntegrationTestFixtureBase // Device1003_Simulated_ABP: used for ABP simulator public TestDeviceInfo Device1003_Simulated_ABP { get; private set; } + // Device1004_Simulated_ABP: used for ABP simulator + public TestDeviceInfo Device1004_Simulated_ABP { get; private set; } + private readonly List deviceRange1000_ABP = new List(); public IReadOnlyCollection DeviceRange1000_ABP => this.deviceRange1000_ABP; @@ -46,6 +50,12 @@ public class IntegrationTestFixtureSim : IntegrationTestFixtureBase public IReadOnlyCollection DeviceRange6000_OTAA_FullLoad { get; private set; } public IReadOnlyCollection DeviceRange9000_OTAA_FullLoad_DuplicationDrop { get; private set; } + public override async Task InitializeAsync() + { + await base.InitializeAsync(); + LoRaAPIHelper.Initialize(Configuration.FunctionAppCode, Configuration.FunctionAppBaseUrl); + } + public override void SetupTestDevices() { var gatewayID = Environment.GetEnvironmentVariable("IOTEDGE_DEVICEID") ?? Configuration.LeafDeviceGatewayID; @@ -87,6 +97,19 @@ public override void SetupTestDevices() DevAddr = new DevAddr(0x00001003), }; + // Device1004_Simulated_ABP: used for simulator + Device1004_Simulated_ABP = new TestDeviceInfo() + { + DeviceID = "0000000000001004", + Deduplication = DeduplicationMode.Drop, + SensorDecoder = "DecoderValueSensor", + IsIoTHubDevice = true, + AppSKey = GetAppSessionKey(1004), + NwkSKey = GetNetworkSessionKey(1004), + DevAddr = new DevAddr(0x00001004), + ClassType = LoRaDeviceClassType.C + }; + var fileName = "EU863.json"; var jsonString = File.ReadAllText(fileName); diff --git a/Tests/Simulation/SimulatedCloudTests.cs b/Tests/Simulation/SimulatedCloudTests.cs new file mode 100644 index 0000000000..2935598d8f --- /dev/null +++ b/Tests/Simulation/SimulatedCloudTests.cs @@ -0,0 +1,125 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace LoRaWan.Tests.Simulation +{ + using System; + using System.Collections.Generic; + using System.Globalization; + using System.Linq; + using System.Threading.Tasks; + using LoRaTools.CommonAPI; + using LoRaWan.Tests.Common; + using Microsoft.Extensions.Logging; + using Newtonsoft.Json; + using Xunit; + using Xunit.Abstractions; + using static MoreLinq.Extensions.RepeatExtension; + + [Trait("Category", "SkipWhenLiveUnitTesting")] + public sealed class SimulatedCloudTests : IntegrationTestBaseSim, IAsyncLifetime + { + private readonly List simulatedBasicsStations; + /// + /// A unique upstream message fragment is used for each uplink message to ensure + /// that there is no interference between test runs. + /// + private readonly string uniqueMessageFragment; + private readonly TestOutputLogger logger; + + public TestConfiguration Configuration { get; } = TestConfiguration.GetConfiguration(); + + public SimulatedCloudTests(IntegrationTestFixtureSim testFixture, ITestOutputHelper testOutputHelper) + : base(testFixture) + { + this.uniqueMessageFragment = Guid.NewGuid().ToString(); + this.logger = new TestOutputLogger(testOutputHelper); + this.simulatedBasicsStations = + testFixture.DeviceRange5000_BasicsStationSimulators + .Zip(Configuration.LnsEndpointsForSimulator.Repeat(), + (tdi, lnsNameToUrl) => new SimulatedBasicsStation(StationEui.Parse(tdi.DeviceID), lnsNameToUrl.Value)) + .ToList(); + + Assert.True(this.simulatedBasicsStations.Count % Configuration.LnsEndpointsForSimulator.Count == 0, "Since Basics Stations are round-robin distributed to LNS, we must have the same number of stations per LNS for well-defined test assertions."); + } + + [Fact] + public async Task Single_ABP_Simulated_Device_Sends_And_Receives_C2D() + { + var testDeviceInfo = TestFixtureSim.Device1004_Simulated_ABP; + LogTestStart(testDeviceInfo); + + const int messageCount = 5; + var device = new SimulatedDevice(testDeviceInfo, simulatedBasicsStation: this.simulatedBasicsStations, logger: this.logger); + + TestLogger.Log($"[INFO] Simulating send of {messageCount} messages from {device.LoRaDevice.DeviceID}"); + await SimulationUtils.SendConfirmedUpstreamMessages(device, messageCount, this.uniqueMessageFragment); + + var c2dMessageBody = (100 + Random.Shared.Next(90)).ToString(CultureInfo.InvariantCulture); + var c2dMessage = new LoRaCloudToDeviceMessage() + { + Payload = c2dMessageBody, + Fport = FramePorts.App1, + MessageId = Guid.NewGuid().ToString(), + }; + + // Now sending a c2d + var c2d = new LoRaCloudToDeviceMessage() + { + DevEUI = device.DevEUI, + MessageId = Guid.NewGuid().ToString(), + Fport = FramePorts.App23, + RawPayload = Convert.ToBase64String(new byte[] { 0xFF, 0x00 }), + }; + + TestLogger.Log($"[INFO] Using service API to send C2D message to device {device.LoRaDevice.DeviceID}"); + TestLogger.Log($"[INFO] {JsonConvert.SerializeObject(c2d, Formatting.None)}"); + + // send message using the SendCloudToDeviceMessage API endpoint + Assert.True(await LoRaAPIHelper.SendCloudToDeviceMessage(device.DevEUI, c2d)); + + var c2dLogMessage = $"{device.LoRaDevice.DeviceID}: received cloud to device message from direct method"; + TestLogger.Log($"[INFO] Searching for following log in LNS logs: '{c2dLogMessage}'"); + + var searchResults = await TestFixture.SearchNetworkServerModuleAsync( + messageBody => messageBody.StartsWith(c2dLogMessage, StringComparison.OrdinalIgnoreCase), + new SearchLogOptions(c2dLogMessage) + { + MaxAttempts = 1 + }); + + Assert.True(searchResults.Found, $"Did not find '{device.LoRaDevice.DeviceID}: C2D log: {c2dLogMessage}' in logs"); + + TestLogger.Log($"[INFO] Asserting all messages were received in IoT Hub for device {device.LoRaDevice.DeviceID}"); + await SimulationUtils.AssertIotHubMessageCountAsync(device, + messageCount, + this.uniqueMessageFragment, + this.logger, + this.simulatedBasicsStations.Count, + TestFixture.IoTHubMessages, + Configuration.LnsEndpointsForSimulator.Count); + } + + public async Task InitializeAsync() + { + await Task.WhenAll(from basicsStation in this.simulatedBasicsStations + select basicsStation.StartAsync()); + } + + public async Task DisposeAsync() + { + foreach (var basicsStation in this.simulatedBasicsStations) + { + try + { + await basicsStation.StopAndValidateAsync(); + basicsStation.Dispose(); + } + catch (Exception) + { + // Dispose all basics stations + } + } + } + } +} diff --git a/Tests/Simulation/SimulatedLoadTests.cs b/Tests/Simulation/SimulatedLoadTests.cs index 263a9200f1..6f7940b901 100644 --- a/Tests/Simulation/SimulatedLoadTests.cs +++ b/Tests/Simulation/SimulatedLoadTests.cs @@ -8,12 +8,8 @@ namespace LoRaWan.Tests.Simulation using System.Diagnostics; using System.Globalization; using System.Linq; - using System.Runtime.CompilerServices; using System.Security.Cryptography; - using System.Text; - using System.Text.Json; using System.Threading.Tasks; - using Azure.Messaging.EventHubs; using LoRaWan.Tests.Common; using Microsoft.Extensions.Logging; using NetworkServer; @@ -26,7 +22,6 @@ namespace LoRaWan.Tests.Simulation [Trait("Category", "SkipWhenLiveUnitTesting")] public sealed class SimulatedLoadTests : IntegrationTestBaseSim, IAsyncLifetime { - private const double DownstreamDroppedMessagesTolerance = 0.02; private static readonly TimeSpan IntervalBetweenMessages = TimeSpan.FromSeconds(5); private readonly List simulatedBasicsStations; /// @@ -60,16 +55,22 @@ public async Task Five_Devices_Sending_Messages_At_Same_Time() // arrange const int messageCount = 2; - var simulatedDevices = InitializeSimulatedDevices(testDeviceInfo); + var simulatedDevices = SimulationUtils.InitializeSimulatedDevices(testDeviceInfo, this.simulatedBasicsStations, logger); Assert.NotEmpty(simulatedDevices); // act await Task.WhenAll(from device in simulatedDevices - select SendConfirmedUpstreamMessages(device, messageCount)); + select SimulationUtils.SendConfirmedUpstreamMessages(device, messageCount, this.uniqueMessageFragment)); // assert - await AssertIotHubMessageCountsAsync(simulatedDevices, messageCount); - AssertMessageAcknowledgements(simulatedDevices, messageCount); + await SimulationUtils.AssertIotHubMessageCountsAsync(simulatedDevices, + messageCount, + this.uniqueMessageFragment, + this.logger, + this.simulatedBasicsStations.Count, + TestFixture.IoTHubMessages, + Configuration.LnsEndpointsForSimulator.Count); + SimulationUtils.AssertMessageAcknowledgements(simulatedDevices, messageCount); } [Fact] @@ -81,10 +82,10 @@ public async Task Single_ABP_Simulated_Device() const int messageCount = 5; var device = new SimulatedDevice(testDeviceInfo, simulatedBasicsStation: this.simulatedBasicsStations, logger: this.logger); - await SendConfirmedUpstreamMessages(device, messageCount); + await SimulationUtils.SendConfirmedUpstreamMessages(device, messageCount, this.uniqueMessageFragment); - await AssertIotHubMessageCountAsync(device, messageCount); - AssertMessageAcknowledgement(device, messageCount); + await SimulationUtils.AssertIotHubMessageCountAsync(device, messageCount, this.uniqueMessageFragment, this.logger, this.simulatedBasicsStations.Count, TestFixture.IoTHubMessages, Configuration.LnsEndpointsForSimulator.Count); + SimulationUtils.AssertMessageAcknowledgement(device, messageCount); } [Fact] @@ -96,7 +97,7 @@ public async Task Ensures_Disconnect_Happens_For_Losing_Gateway_When_Connection_ var messagesToSendEachLNS = 3; var simulatedDevice = new SimulatedDevice(testDeviceInfo, simulatedBasicsStation: new[] { this.simulatedBasicsStations.First() }, logger: this.logger); - await SendConfirmedUpstreamMessages(simulatedDevice, messagesToSendEachLNS); + await SimulationUtils.SendConfirmedUpstreamMessages(simulatedDevice, messagesToSendEachLNS, this.uniqueMessageFragment); await Task.Delay(messagesToSendEachLNS * IntervalBetweenMessages); _ = await TestFixture.AssertNetworkServerModuleLogExistsAsync( @@ -105,14 +106,14 @@ public async Task Ensures_Disconnect_Happens_For_Losing_Gateway_When_Connection_ // act: change basics station that the device is listened from and therefore the gateway it uses as well simulatedDevice.SimulatedBasicsStations = new[] { this.simulatedBasicsStations.Last() }; - await SendConfirmedUpstreamMessages(simulatedDevice, messagesToSendEachLNS); + await SimulationUtils.SendConfirmedUpstreamMessages(simulatedDevice, messagesToSendEachLNS, this.uniqueMessageFragment); // assert var expectedLnsToDropConnection = Configuration.LnsEndpointsForSimulator.First().Key; _ = await TestFixture.AssertNetworkServerModuleLogExistsAsync( x => x.Contains(LnsRemoteCallHandler.ClosedConnectionLog, StringComparison.Ordinal) && x.Contains(expectedLnsToDropConnection, StringComparison.Ordinal), new SearchLogOptions($"{LnsRemoteCallHandler.ClosedConnectionLog} and {expectedLnsToDropConnection}") { TreatAsError = true }); - await AssertIotHubMessageCountAsync(simulatedDevice, messagesToSendEachLNS * 2); + await SimulationUtils.AssertIotHubMessageCountAsync(simulatedDevice, messagesToSendEachLNS * 2, this.uniqueMessageFragment, this.logger, this.simulatedBasicsStations.Count, TestFixture.IoTHubMessages, Configuration.LnsEndpointsForSimulator.Count); } [Fact] @@ -125,10 +126,10 @@ public async Task Single_OTAA_Simulated_Device() var device = new SimulatedDevice(testDeviceInfo, simulatedBasicsStation: this.simulatedBasicsStations, logger: this.logger); Assert.True(await device.JoinAsync(), "OTAA join failed"); - await SendConfirmedUpstreamMessages(device, messageCount); + await SimulationUtils.SendConfirmedUpstreamMessages(device, messageCount, this.uniqueMessageFragment); - await AssertIotHubMessageCountAsync(device, messageCount); - AssertMessageAcknowledgement(device, messageCount + 1); + await SimulationUtils.AssertIotHubMessageCountAsync(device, messageCount, this.uniqueMessageFragment, this.logger, this.simulatedBasicsStations.Count, TestFixture.IoTHubMessages, Configuration.LnsEndpointsForSimulator.Count); + SimulationUtils.AssertMessageAcknowledgement(device, messageCount + 1); } [Fact] @@ -139,7 +140,7 @@ public async Task Lots_Of_Devices_OTAA_Simulated_Load_Test() // arrange const int messageCounts = 10; - var simulatedDevices = InitializeSimulatedDevices(testDeviceInfo); + var simulatedDevices = SimulationUtils.InitializeSimulatedDevices(testDeviceInfo, this.simulatedBasicsStations, this.logger); Assert.NotEmpty(simulatedDevices); // act @@ -151,12 +152,12 @@ async Task ActAsync(SimulatedDevice device, TimeSpan startOffset) { await Task.Delay(startOffset); Assert.True(await device.JoinAsync(), "OTAA join failed"); - await SendConfirmedUpstreamMessages(device, messageCounts); + await SimulationUtils.SendConfirmedUpstreamMessages(device, messageCounts, this.uniqueMessageFragment); } // assert - await AssertIotHubMessageCountsAsync(simulatedDevices, messageCounts); - AssertMessageAcknowledgements(simulatedDevices, messageCounts + 1); + await SimulationUtils.AssertIotHubMessageCountsAsync(simulatedDevices, messageCounts, this.uniqueMessageFragment, this.logger, this.simulatedBasicsStations.Count, TestFixture.IoTHubMessages, Configuration.LnsEndpointsForSimulator.Count); + SimulationUtils.AssertMessageAcknowledgements(simulatedDevices, messageCounts + 1); } /// @@ -190,7 +191,7 @@ public async Task Connected_Factory_Load_Test_Scenario() .Take(numberOfFactories) .Zip(testDeviceInfo.Chunk(testDeviceInfo.Count / numberOfFactories) .Take(numberOfFactories), - (ss, ds) => ds.Select(d => InitializeSimulatedDevice(d, ss)).ToList()); + (ss, ds) => ds.Select(d => SimulationUtils.InitializeSimulatedDevice(d, ss, this.logger)).ToList()); // Cache the devices in a flat list to make distributing requests easier. // Transposing the matrix makes sure that device requests are distributed evenly across factories, @@ -213,7 +214,7 @@ await ScheduleForEachAsync(devices, Intervals(TimeSpan.FromSeconds(1) / joinsPer await ScheduleForEachAsync(devices, Intervals(TimeSpan.FromSeconds(1) / messageRate), async d => { - using var request = CreateConfirmedUpstreamMessage(d); + using var request = SimulationUtils.CreateConfirmedUpstreamMessage(d, this.uniqueMessageFragment); await d.SendDataMessageAsync(request); }); } @@ -223,8 +224,8 @@ await ScheduleForEachAsync(devices, Intervals(TimeSpan.FromSeconds(1) / messageR // A correction needs to be applied since concentrators are distributed across LNS, even if they are in the same factory // (detailed description found at the beginning of this test). - await AssertIotHubMessageCountsAsync(devices, numberOfLoops, 1 / (double)numberOfFactories); - AssertMessageAcknowledgements(devices, numberOfLoops + 1); + await SimulationUtils.AssertIotHubMessageCountsAsync(devices, numberOfLoops, this.uniqueMessageFragment, this.logger, this.simulatedBasicsStations.Count, TestFixture.IoTHubMessages, Configuration.LnsEndpointsForSimulator.Count, 1 / (double)numberOfFactories); + SimulationUtils.AssertMessageAcknowledgements(devices, numberOfLoops + 1); static IEnumerable Intervals(TimeSpan step, TimeSpan? initial = null) => MoreLinq.MoreEnumerable.Generate(initial ?? TimeSpan.Zero, ts => ts + step); @@ -253,8 +254,8 @@ public async Task Multiple_ABP_and_OTAA_Simulated_Devices_Confirmed() const int messagesBeforeConfirmed = 5; var warmupDelay = TimeSpan.FromSeconds(5); - var simulatedAbpDevices = InitializeSimulatedDevices(testAbpDevicesInfo); - var simulatedOtaaDevices = InitializeSimulatedDevices(testOtaaDevicesInfo); + var simulatedAbpDevices = SimulationUtils.InitializeSimulatedDevices(testAbpDevicesInfo, this.simulatedBasicsStations, this.logger); + var simulatedOtaaDevices = SimulationUtils.InitializeSimulatedDevices(testOtaaDevicesInfo, this.simulatedBasicsStations, this.logger); Assert.Equal(simulatedAbpDevices.Count, simulatedOtaaDevices.Count); Assert.True(simulatedOtaaDevices.Count < 50, "Simulator does not work for more than 50 of each devices (due to IoT Edge connection mode). To go beyond 100 device clients, use edge hub environment variable 'MaxConnectedClients'."); Assert.True(messagesBeforeConfirmed <= messagesBeforeJoin, "OTAA devices should send all messages as confirmed messages."); @@ -315,99 +316,15 @@ static async Task JoinAsync(SimulatedDevice device) // 3. Check that the correct number of messages have arrived in IoT Hub per device // Warn only. - await AssertIotHubMessageCountsAsync(simulatedAbpDevices, messagesPerDeviceExcludingWarmup); - AssertMessageAcknowledgements(simulatedAbpDevices, messagesPerDeviceExcludingWarmup - messagesBeforeConfirmed); + await SimulationUtils.AssertIotHubMessageCountsAsync(simulatedAbpDevices, messagesPerDeviceExcludingWarmup, this.uniqueMessageFragment, this.logger, this.simulatedBasicsStations.Count, TestFixture.IoTHubMessages, Configuration.LnsEndpointsForSimulator.Count); + SimulationUtils.AssertMessageAcknowledgements(simulatedAbpDevices, messagesPerDeviceExcludingWarmup - messagesBeforeConfirmed); // number of total data messages is number of messages per device minus the join message minus the number of messages sent before the join happens. const int numberOfOtaaDataMessages = messagesPerDeviceExcludingWarmup - messagesBeforeJoin - 1; - await AssertIotHubMessageCountsAsync(simulatedOtaaDevices, numberOfOtaaDataMessages, disableWaitForIotHub: true); - AssertMessageAcknowledgements(simulatedOtaaDevices, numberOfOtaaDataMessages + 1); + await SimulationUtils.AssertIotHubMessageCountsAsync(simulatedOtaaDevices, numberOfOtaaDataMessages, this.uniqueMessageFragment, this.logger, this.simulatedBasicsStations.Count, TestFixture.IoTHubMessages, Configuration.LnsEndpointsForSimulator.Count, disableWaitForIotHub: true); + SimulationUtils.AssertMessageAcknowledgements(simulatedOtaaDevices, numberOfOtaaDataMessages + 1); } - private static void AssertMessageAcknowledgement(SimulatedDevice device, int expectedCount) => - AssertMessageAcknowledgements(new[] { device }, expectedCount); - - private static void AssertMessageAcknowledgements(IEnumerable devices, int expectedCount) - { - if (expectedCount == 0) throw new ArgumentException(null, nameof(expectedCount)); - - foreach (var device in devices) - { - var minimumMessagesReceived = Math.Max((int)(expectedCount * (1 - DownstreamDroppedMessagesTolerance)), 1); - Assert.True(minimumMessagesReceived <= device.ReceivedMessages.Count, $"Too many downlink messages were dropped. Received {device.ReceivedMessages.Count} messages but expected at least {minimumMessagesReceived}."); - } - } - - private async Task SendConfirmedUpstreamMessages(SimulatedDevice device, int count) - { - for (var i = 0; i < count; ++i) - { - using var request = CreateConfirmedUpstreamMessage(device); - await device.SendDataMessageAsync(request); - await Task.Delay(IntervalBetweenMessages); - } - } - - private WaitableLoRaRequest CreateConfirmedUpstreamMessage(SimulatedDevice simulatedDevice) => - WaitableLoRaRequest.CreateWaitableRequest(simulatedDevice.CreateConfirmedDataUpMessage(this.uniqueMessageFragment + Guid.NewGuid())); - - private Task AssertIotHubMessageCountAsync(SimulatedDevice device, int numberOfMessages) => - AssertIotHubMessageCountsAsync(new[] { device }, numberOfMessages); - - private async Task AssertIotHubMessageCountsAsync(IEnumerable devices, - int numberOfMessages, - double? correction = null, - bool disableWaitForIotHub = false) - { - // Wait for messages in IoT Hub. - if (!disableWaitForIotHub) - { - await Task.Delay(TimeSpan.FromSeconds(100)); - } - - var actualMessageCounts = new Dictionary(); - foreach (var device in devices) - { - actualMessageCounts.Add(device.DevEUI, TestFixture.IoTHubMessages.Events.Count(e => ContainsMessageFromDevice(e, device))); - } - - bool ContainsMessageFromDevice(EventData eventData, SimulatedDevice simulatedDevice) - { - if (eventData.Properties.ContainsKey("iothub-message-schema")) return false; - if (eventData.GetDeviceId() != simulatedDevice.LoRaDevice.DeviceID) return false; - return Encoding.UTF8.GetString(eventData.EventBody).Contains(this.uniqueMessageFragment, StringComparison.Ordinal); - } - - this.logger.LogInformation("Message counts by DevEui:"); - this.logger.LogInformation(JsonSerializer.Serialize(actualMessageCounts.ToDictionary(kv => kv.Key.ToString(), kv => kv.Value))); - - foreach (var device in devices) - { - var expectedMessageCount = device.LoRaDevice.Deduplication switch - { - DeduplicationMode.None or DeduplicationMode.Mark => numberOfMessages * this.simulatedBasicsStations.Count, - DeduplicationMode.Drop => numberOfMessages, - var mode => throw new SwitchExpressionException(mode) - }; - - if (!string.IsNullOrEmpty(device.LoRaDevice.GatewayID)) - { - expectedMessageCount /= Configuration.LnsEndpointsForSimulator.Count; - } - - var applicableMessageCount = correction is { } someCorrection ? expectedMessageCount * someCorrection : expectedMessageCount; - var actualMessageCount = actualMessageCounts[device.DevEUI]; - // Takes into account at-least-once delivery guarantees. - Assert.True(applicableMessageCount <= actualMessageCount, $"Expected at least {applicableMessageCount} IoT Hub messages for device {device.DevEUI} but counted {actualMessageCount}."); - } - } - - private List InitializeSimulatedDevices(IReadOnlyCollection testDeviceInfos) => - testDeviceInfos.Select(d => InitializeSimulatedDevice(d, this.simulatedBasicsStations)).ToList(); - - private SimulatedDevice InitializeSimulatedDevice(TestDeviceInfo testDeviceInfo, IReadOnlyCollection simulatedBasicsStations) => - new SimulatedDevice(testDeviceInfo, simulatedBasicsStation: simulatedBasicsStations, logger: this.logger); - public async Task InitializeAsync() { await Task.WhenAll(from basicsStation in this.simulatedBasicsStations diff --git a/Tests/Simulation/SimulationUtils.cs b/Tests/Simulation/SimulationUtils.cs new file mode 100644 index 0000000000..35c79574be --- /dev/null +++ b/Tests/Simulation/SimulationUtils.cs @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace LoRaWan.Tests.Simulation +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Runtime.CompilerServices; + using System.Text; + using System.Text.Json; + using System.Threading.Tasks; + using Azure.Messaging.EventHubs; + using LoRaWan.NetworkServer; + using LoRaWan.Tests.Common; + using Microsoft.Extensions.Logging; + using Xunit; + + internal class SimulationUtils + { + private const double DownstreamDroppedMessagesTolerance = 0.02; + + internal static void AssertMessageAcknowledgement(SimulatedDevice device, int expectedCount) => + AssertMessageAcknowledgements(new[] { device }, expectedCount); + + internal static void AssertMessageAcknowledgements(IEnumerable devices, int expectedCount) + { + if (expectedCount == 0) throw new ArgumentException(null, nameof(expectedCount)); + + foreach (var device in devices) + { + var minimumMessagesReceived = Math.Max((int)(expectedCount * (1 - DownstreamDroppedMessagesTolerance)), 1); + Assert.True(minimumMessagesReceived <= device.ReceivedMessages.Count, $"Too many downlink messages were dropped. Received {device.ReceivedMessages.Count} messages but expected at least {minimumMessagesReceived}."); + } + } + + internal static async Task SendConfirmedUpstreamMessages(SimulatedDevice device, int count, string uniqueMessageFragment, int intervalBetweenMessagesInSeconds = 5) + { + for (var i = 0; i < count; ++i) + { + using var request = CreateConfirmedUpstreamMessage(device, uniqueMessageFragment); + await device.SendDataMessageAsync(request); + await Task.Delay(TimeSpan.FromSeconds(intervalBetweenMessagesInSeconds)); + } + } + + internal static WaitableLoRaRequest CreateConfirmedUpstreamMessage(SimulatedDevice simulatedDevice, string uniqueMessageFragment) => + WaitableLoRaRequest.CreateWaitableRequest(simulatedDevice.CreateConfirmedDataUpMessage(uniqueMessageFragment + Guid.NewGuid())); + + internal static Task AssertIotHubMessageCountAsync(SimulatedDevice device, + int numberOfMessages, + string uniqueMessageFragment, + TestOutputLogger logger, + int basicStationCount, + EventHubDataCollector hubDataCollector, + int lnsEndpointsForSimulatorCount) => + AssertIotHubMessageCountsAsync(new[] { device }, + numberOfMessages, + uniqueMessageFragment, + logger, + basicStationCount, + hubDataCollector, + lnsEndpointsForSimulatorCount); + + internal static async Task AssertIotHubMessageCountsAsync(IEnumerable devices, + int numberOfMessages, + string uniqueMessageFragment, + TestOutputLogger logger, + int basicStationCount, + EventHubDataCollector hubDataCollector, + int lnsEndpointsForSimulatorCount, + double? correction = null, + bool disableWaitForIotHub = false) + { + // Wait for messages in IoT Hub. + if (!disableWaitForIotHub) + { + await Task.Delay(TimeSpan.FromSeconds(100)); + } + + var actualMessageCounts = new Dictionary(); + foreach (var device in devices) + { + actualMessageCounts.Add(device.DevEUI, hubDataCollector.Events.Count(e => ContainsMessageFromDevice(e, device))); + } + + bool ContainsMessageFromDevice(EventData eventData, SimulatedDevice simulatedDevice) + { + if (eventData.Properties.ContainsKey("iothub-message-schema")) return false; + if (eventData.GetDeviceId() != simulatedDevice.LoRaDevice.DeviceID) return false; + return Encoding.UTF8.GetString(eventData.EventBody).Contains(uniqueMessageFragment, StringComparison.Ordinal); + } + + logger.Log(LogLevel.Information, "Message counts by DevEui:"); + logger.Log(LogLevel.Information, JsonSerializer.Serialize(actualMessageCounts.ToDictionary(kv => kv.Key.ToString(), kv => kv.Value))); + + foreach (var device in devices) + { + var expectedMessageCount = device.LoRaDevice.Deduplication switch + { + DeduplicationMode.None or DeduplicationMode.Mark => numberOfMessages * basicStationCount, + DeduplicationMode.Drop => numberOfMessages, + var mode => throw new SwitchExpressionException(mode) + }; + + if (!string.IsNullOrEmpty(device.LoRaDevice.GatewayID)) + { + expectedMessageCount /= lnsEndpointsForSimulatorCount; + } + + var applicableMessageCount = correction is { } someCorrection ? expectedMessageCount * someCorrection : expectedMessageCount; + var actualMessageCount = actualMessageCounts[device.DevEUI]; + // Takes into account at-least-once delivery guarantees. + Assert.True(applicableMessageCount <= actualMessageCount, $"Expected at least {applicableMessageCount} IoT Hub messages for device {device.DevEUI} but counted {actualMessageCount}."); + } + } + + internal static List InitializeSimulatedDevices(IReadOnlyCollection testDeviceInfos, + IReadOnlyCollection simulatedBasicsStations, + TestOutputLogger logger) => + testDeviceInfos.Select(d => InitializeSimulatedDevice(d, simulatedBasicsStations, logger)).ToList(); + + internal static SimulatedDevice InitializeSimulatedDevice(TestDeviceInfo testDeviceInfo, + IReadOnlyCollection simulatedBasicsStations, + TestOutputLogger logger) => + new SimulatedDevice(testDeviceInfo, simulatedBasicsStation: simulatedBasicsStations, logger: logger); + } +} diff --git a/Tests/Simulation/appsettings.json b/Tests/Simulation/appsettings.json index e26afe40c6..1e4afd5391 100644 --- a/Tests/Simulation/appsettings.json +++ b/Tests/Simulation/appsettings.json @@ -6,11 +6,14 @@ "NetworkServerModuleLogAssertLevel": "Error", "IoTHubAssertLevel": "Warning", "TcpLog": true, + "TcpLogPort": "#{INTEGRATIONTEST_TcpLogPort}#", "CreateDevices": true, "LeafDeviceGatewayID": "#{INTEGRATIONTEST_LeafDeviceGatewayID}#", "DevicePrefix": "#{INTEGRATIONTEST_DevicePrefix}#", "LoadTestLnsEndpoints": "#{INTEGRATIONTEST_LoadTestLnsEndpoints}#", "NumberOfLoadTestDevices": "#{INTEGRATIONTEST_NumberOfLoadTestDevices}#", - "NumberOfLoadTestConcentrators": "#{INTEGRATIONTEST_NumberOfLoadTestConcentrators}#" + "NumberOfLoadTestConcentrators": "#{INTEGRATIONTEST_NumberOfLoadTestConcentrators}#", + "FunctionAppCode": "#{INTEGRATIONTEST_FunctionAppCode}#", + "FunctionAppBaseUrl": "#{INTEGRATIONTEST_FunctionAppBaseUrl}#" } } From a601d320b06de69596d05145d8ae2f78db862cfd Mon Sep 17 00:00:00 2001 From: danigian <1955514+danigian@users.noreply.github.com> Date: Fri, 1 Jul 2022 09:44:10 +0200 Subject: [PATCH 17/22] Remove useless assignment --- Tests/Unit/NetworkServer/ConfigurationTest.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/Unit/NetworkServer/ConfigurationTest.cs b/Tests/Unit/NetworkServer/ConfigurationTest.cs index e52434bec2..93e2beaf98 100644 --- a/Tests/Unit/NetworkServer/ConfigurationTest.cs +++ b/Tests/Unit/NetworkServer/ConfigurationTest.cs @@ -108,12 +108,12 @@ public void EnableGatewayTrue_IoTModuleFalse_IsNotSupported(bool cloud_deploymen if (cloud_deployment && enable_gateway) { Assert.Throws(() => { - var networkServerConfiguration = NetworkServerConfiguration.CreateFromEnvironmentVariables(); + _ = NetworkServerConfiguration.CreateFromEnvironmentVariables(); }); } else { - var networkServerConfiguration = NetworkServerConfiguration.CreateFromEnvironmentVariables(); + _ = NetworkServerConfiguration.CreateFromEnvironmentVariables(); } } finally From fe8acc90d7fa725d2ff106273f7e8b3b87070920 Mon Sep 17 00:00:00 2001 From: danigian <1955514+danigian@users.noreply.github.com> Date: Fri, 1 Jul 2022 09:44:27 +0200 Subject: [PATCH 18/22] Remove useless assignment and rename --- Tests/Unit/NetworkServer/ModuleConnectionHostTest.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Tests/Unit/NetworkServer/ModuleConnectionHostTest.cs b/Tests/Unit/NetworkServer/ModuleConnectionHostTest.cs index 7e2c15fd7a..a04330260a 100644 --- a/Tests/Unit/NetworkServer/ModuleConnectionHostTest.cs +++ b/Tests/Unit/NetworkServer/ModuleConnectionHostTest.cs @@ -139,7 +139,7 @@ public async Task On_Desired_Properties_Incorrect_Update_Should_Not_Update_Proce [Fact] public async Task InitModuleAsync_Update_Should_Perform_Happy_Path() { - var networkServerConfiguration = new NetworkServerConfiguration() + var timeotNetworkServerConfiguration = new NetworkServerConfiguration() { // Change the iot edge timeout. IoTEdgeTimeout = 5 @@ -161,11 +161,11 @@ public async Task InitModuleAsync_Update_Should_Perform_Happy_Path() loRaModuleClient.Setup(x => x.GetTwinAsync(It.IsAny())).ReturnsAsync(new Twin(twinProperty)); - await using var moduleClient = new ModuleConnectionHost(networkServerConfiguration, this.loRaModuleClientFactory.Object, loRaDeviceApiServiceBase, this.lnsRemoteCall.Object, NullLogger.Instance, TestMeter.Instance); + await using var moduleClient = new ModuleConnectionHost(timeotNetworkServerConfiguration, this.loRaModuleClientFactory.Object, loRaDeviceApiServiceBase, this.lnsRemoteCall.Object, NullLogger.Instance, TestMeter.Instance); await moduleClient.CreateAsync(CancellationToken.None); Assert.Equal(facadeUri + "/", loRaDeviceApiServiceBase.URL.ToString()); Assert.Equal(facadeCode, loRaDeviceApiServiceBase.AuthCode); - Assert.Equal(processingDelay, networkServerConfiguration.ProcessingDelayInMilliseconds); + Assert.Equal(processingDelay, timeotNetworkServerConfiguration.ProcessingDelayInMilliseconds); } [Theory] @@ -256,7 +256,7 @@ public async Task OnDirectMethodCall_Should_Invoke_SendCloudToDeviceMessageAsync var methodRequest = new MethodRequest(Constants.CloudToDeviceDecoderElementName, Encoding.UTF8.GetBytes(json)); // act - var result = await this.subject.OnDirectMethodCalled(methodRequest, null); + await this.subject.OnDirectMethodCalled(methodRequest, null); // assert this.lnsRemoteCall.Verify(l => l.ExecuteAsync(new LnsRemoteCall(RemoteCallKind.CloudToDeviceMessage, json), CancellationToken.None), Times.Once); From 9931c91fe80ab77f3f7cd97778d2f3c61a5a0ed9 Mon Sep 17 00:00:00 2001 From: danigian <1955514+danigian@users.noreply.github.com> Date: Fri, 1 Jul 2022 09:53:14 +0200 Subject: [PATCH 19/22] Fixing possible null reference --- .../LoRaWan.NetworkServer/LnsRemoteCallListener.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LnsRemoteCallListener.cs b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LnsRemoteCallListener.cs index 68fac61781..5ade6cbcda 100644 --- a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LnsRemoteCallListener.cs +++ b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LnsRemoteCallListener.cs @@ -42,8 +42,15 @@ public async Task SubscribeAsync(string lns, Func function, { try { - var lnsRemoteCall = JsonSerializer.Deserialize(value.Message) ?? throw new InvalidOperationException("Deserialization produced an empty LnsRemoteCall."); - return function(lnsRemoteCall); + if (value is { Message: { } m } && !m.IsNullOrEmpty) + { + var lnsRemoteCall = JsonSerializer.Deserialize(m.ToString()) ?? throw new InvalidOperationException("Deserialization produced an empty LnsRemoteCall."); + return function(lnsRemoteCall); + } + else + { + throw new ArgumentNullException(nameof(value)); + } } catch (Exception ex) when (ExceptionFilterUtility.False(() => this.logger.LogError(ex, $"An exception occurred when reacting to a Redis message: '{ex}'."), () => this.unhandledExceptionCount.Add(1))) From bd674c543d503df477ef2f5012d1438b6c82d7c8 Mon Sep 17 00:00:00 2001 From: danigian <1955514+danigian@users.noreply.github.com> Date: Fri, 1 Jul 2022 10:01:56 +0200 Subject: [PATCH 20/22] ArgumentNullException in integration tests --- Tests/Integration/RedisRemoteCallListenerTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/Integration/RedisRemoteCallListenerTests.cs b/Tests/Integration/RedisRemoteCallListenerTests.cs index da78fa18e9..bb872046b8 100644 --- a/Tests/Integration/RedisRemoteCallListenerTests.cs +++ b/Tests/Integration/RedisRemoteCallListenerTests.cs @@ -90,7 +90,7 @@ public async Task SubscribeAsync_Exceptions_Are_Tracked() // assert var invocation = await RetryAssertSingleAsync(this.logger.GetLogInvocations()); - _ = Assert.IsType(invocation.Exception); + _ = Assert.IsType(invocation.Exception); } private async Task PublishAsync(string channel, LnsRemoteCall lnsRemoteCall) From a0f45781507d9c92bb611c3a7ceeeb6c0ccac482 Mon Sep 17 00:00:00 2001 From: danigian <1955514+danigian@users.noreply.github.com> Date: Fri, 1 Jul 2022 10:09:20 +0200 Subject: [PATCH 21/22] Get rid of useless assignment --- Tests/Simulation/SimulatedCloudTests.cs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/Tests/Simulation/SimulatedCloudTests.cs b/Tests/Simulation/SimulatedCloudTests.cs index 2935598d8f..bd1a6db4e5 100644 --- a/Tests/Simulation/SimulatedCloudTests.cs +++ b/Tests/Simulation/SimulatedCloudTests.cs @@ -55,14 +55,6 @@ public async Task Single_ABP_Simulated_Device_Sends_And_Receives_C2D() TestLogger.Log($"[INFO] Simulating send of {messageCount} messages from {device.LoRaDevice.DeviceID}"); await SimulationUtils.SendConfirmedUpstreamMessages(device, messageCount, this.uniqueMessageFragment); - var c2dMessageBody = (100 + Random.Shared.Next(90)).ToString(CultureInfo.InvariantCulture); - var c2dMessage = new LoRaCloudToDeviceMessage() - { - Payload = c2dMessageBody, - Fport = FramePorts.App1, - MessageId = Guid.NewGuid().ToString(), - }; - // Now sending a c2d var c2d = new LoRaCloudToDeviceMessage() { From 85fffa45ee7a1e76217d21fa62afe9461b749b30 Mon Sep 17 00:00:00 2001 From: Daniele Antonio Maggio <1955514+danigian@users.noreply.github.com> Date: Sat, 2 Jul 2022 09:22:31 +0200 Subject: [PATCH 22/22] Dereferenced variable may be null --- .../FunctionsBundler/DeduplicationExecutionItem.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LoRaEngine/LoraKeysManagerFacade/FunctionsBundler/DeduplicationExecutionItem.cs b/LoRaEngine/LoraKeysManagerFacade/FunctionsBundler/DeduplicationExecutionItem.cs index 945120f294..6b15339329 100644 --- a/LoRaEngine/LoraKeysManagerFacade/FunctionsBundler/DeduplicationExecutionItem.cs +++ b/LoRaEngine/LoraKeysManagerFacade/FunctionsBundler/DeduplicationExecutionItem.cs @@ -125,7 +125,7 @@ internal async Task GetDuplicateMessageResultAsync(DevEui de var res = await this.serviceClient.InvokeDeviceMethodAsync(previousGateway, LoraKeysManagerFacadeConstants.NetworkServerModuleId, method, default); logger?.LogDebug("Connection owner changed and direct method was called on previous gateway '{PreviousConnectionOwner}' to close connection; result is '{Status}'", previousGateway, res?.Status); - if (!HttpUtilities.IsSuccessStatusCode(res.Status)) + if (res is null || (res is { } && !HttpUtilities.IsSuccessStatusCode(res.Status))) { logger?.LogError("Failed to invoke direct method on LNS '{PreviousConnectionOwner}' to close the connection for device '{DevEUI}'; status '{Status}'", previousGateway, devEUI, res?.Status); }