From 169f1a5195d11a49cf25a572d10de2daf636bfab Mon Sep 17 00:00:00 2001 From: Kevin BEAUGRAND <9513635+kbeaugrand@users.noreply.github.com> Date: Mon, 8 Aug 2022 11:46:54 +0200 Subject: [PATCH] Fix #811 - Add available frequency plans from API and remove hardcoded frequency plans from Web pages --- .../LoRaWANFrequencyPlansControllerTests.cs | 35 +++++++++++++++ .../ConcentratorDetailPageTests.cs | 31 +++++++++++++ .../CreateConcentratorPageTest.cs | 18 ++++++++ .../LoRaWanConcentratorsClientServiceTests.cs | 24 ++++++++++ .../Concentrator/ConcentratorDetailPage.razor | 39 +++++++--------- .../Concentrator/CreateConcentratorPage.razor | 31 ++++++------- .../ILoRaWanConcentratorsClientService.cs | 4 ++ .../LoRaWanConcentratorsClientService.cs | 7 +++ .../Server/AzureIoTHub.Portal.Server.csproj | 4 +- .../LoRaWANFrequencyPlansController.cs | 45 +++++++++++++++++++ .../Models/v1.0/LoRaWAN/FrequencyPlan.cs | 12 +++++ 11 files changed, 209 insertions(+), 41 deletions(-) create mode 100644 src/AzureIoTHub.Portal.Server.Tests.Unit/Controllers/v1.0/LoRaWAN/LoRaWANFrequencyPlansControllerTests.cs create mode 100644 src/AzureIoTHub.Portal/Server/Controllers/v1.0/LoRaWAN/LoRaWANFrequencyPlansController.cs create mode 100644 src/AzureIoTHub.Portal/Shared/Models/v1.0/LoRaWAN/FrequencyPlan.cs diff --git a/src/AzureIoTHub.Portal.Server.Tests.Unit/Controllers/v1.0/LoRaWAN/LoRaWANFrequencyPlansControllerTests.cs b/src/AzureIoTHub.Portal.Server.Tests.Unit/Controllers/v1.0/LoRaWAN/LoRaWANFrequencyPlansControllerTests.cs new file mode 100644 index 000000000..9ed2eebd0 --- /dev/null +++ b/src/AzureIoTHub.Portal.Server.Tests.Unit/Controllers/v1.0/LoRaWAN/LoRaWANFrequencyPlansControllerTests.cs @@ -0,0 +1,35 @@ +// Copyright (c) CGI France. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace AzureIoTHub.Portal.Server.Tests.Unit.Controllers.V10.LoRaWAN +{ + using System.Collections.Generic; + using AzureIoTHub.Portal.Server.Controllers.V10.LoRaWAN; + using AzureIoTHub.Portal.Shared.Models.v10.LoRaWAN; + using Microsoft.AspNetCore.Mvc; + using NUnit.Framework; + + [TestFixture] + public class LoRaWANFrequencyPlansControllerTests : BackendUnitTest + { + private static LoRaWANFrequencyPlansController CreateLoRaWANFrequencyPlansController() + { + return new LoRaWANFrequencyPlansController(); + } + + [Test] + public void GetFrequencyPlans() + { + // Arrange + var loRaWANFrequencyPlansController = CreateLoRaWANFrequencyPlansController(); + + // Act + var result = loRaWANFrequencyPlansController.GetFrequencyPlans(); + + // Assert + Assert.NotNull(result); + Assert.IsAssignableFrom>>(result); + base.MockRepository.VerifyAll(); + } + } +} diff --git a/src/AzureIoTHub.Portal.Server.Tests.Unit/Pages/LoRaWan/Concentrator/ConcentratorDetailPageTests.cs b/src/AzureIoTHub.Portal.Server.Tests.Unit/Pages/LoRaWan/Concentrator/ConcentratorDetailPageTests.cs index b780aa398..3cd4c1f20 100644 --- a/src/AzureIoTHub.Portal.Server.Tests.Unit/Pages/LoRaWan/Concentrator/ConcentratorDetailPageTests.cs +++ b/src/AzureIoTHub.Portal.Server.Tests.Unit/Pages/LoRaWan/Concentrator/ConcentratorDetailPageTests.cs @@ -20,6 +20,7 @@ namespace AzureIoTHub.Portal.Server.Tests.Unit.Pages.LoRaWan.Concentrator using MudBlazor; using MudBlazor.Services; using NUnit.Framework; + using AzureIoTHub.Portal.Shared.Models.v10.LoRaWAN; [TestFixture] public class ConcentratorDetailPageTests : BlazorUnitTest @@ -54,6 +55,12 @@ public void ReturnButtonMustNavigateToPreviousPage() _ = this.mockLoRaWanConcentratorsClientService.Setup(service => service.GetConcentrator(this.mockDeviceId)) .ReturnsAsync(new Concentrator()); + _ = this.mockLoRaWanConcentratorsClientService.Setup(service => service.GetFrequencyPlans()) + .ReturnsAsync(new[] + { + new FrequencyPlan() + }); + var cut = RenderComponent(ComponentParameter.CreateParameter("DeviceID", this.mockDeviceId)); cut.WaitForAssertion(() => cut.Find("#returnButton")); @@ -72,6 +79,12 @@ public void ConcentratorDetailPageShouldProcessProblemDetailsExceptionWhenIssueO _ = this.mockLoRaWanConcentratorsClientService.Setup(service => service.GetConcentrator(this.mockDeviceId)) .ThrowsAsync(new ProblemDetailsException(new ProblemDetailsWithExceptionDetails())); + _ = this.mockLoRaWanConcentratorsClientService.Setup(service => service.GetFrequencyPlans()) + .ReturnsAsync(new[] + { + new FrequencyPlan() + }); + // Act var cut = RenderComponent(ComponentParameter.CreateParameter("DeviceID", this.mockDeviceId)); cut.WaitForAssertion(() => cut.Find("#returnButton")); @@ -91,6 +104,12 @@ public void ClickOnSaveShouldPutConcentratorDetails() LoraRegion = Guid.NewGuid().ToString() }; + _ = this.mockLoRaWanConcentratorsClientService.Setup(service => service.GetFrequencyPlans()) + .ReturnsAsync(new[] + { + new FrequencyPlan() + }); + _ = this.mockLoRaWanConcentratorsClientService.Setup(service => service.GetConcentrator(mockConcentrator.DeviceId)) .ReturnsAsync(mockConcentrator); @@ -120,6 +139,12 @@ public void ConcentratorShouldNotBeUpdatedWhenModelIsNotValid() LoraRegion = Guid.NewGuid().ToString() }; + _ = this.mockLoRaWanConcentratorsClientService.Setup(service => service.GetFrequencyPlans()) + .ReturnsAsync(new[] + { + new FrequencyPlan() + }); + _ = this.mockLoRaWanConcentratorsClientService.Setup(service => service.GetConcentrator(mockConcentrator.DeviceId)) .ReturnsAsync(mockConcentrator); @@ -144,6 +169,12 @@ public void ClickOnSaveShouldProcessProblemDetailsExceptionWhenIssueOccursOnUpda LoraRegion = Guid.NewGuid().ToString() }; + _ = this.mockLoRaWanConcentratorsClientService.Setup(service => service.GetFrequencyPlans()) + .ReturnsAsync(new[] + { + new FrequencyPlan() + }); + _ = this.mockLoRaWanConcentratorsClientService.Setup(service => service.GetConcentrator(mockConcentrator.DeviceId)) .ReturnsAsync(mockConcentrator); diff --git a/src/AzureIoTHub.Portal.Server.Tests.Unit/Pages/LoRaWan/Concentrator/CreateConcentratorPageTest.cs b/src/AzureIoTHub.Portal.Server.Tests.Unit/Pages/LoRaWan/Concentrator/CreateConcentratorPageTest.cs index cd7895870..7e30c2836 100644 --- a/src/AzureIoTHub.Portal.Server.Tests.Unit/Pages/LoRaWan/Concentrator/CreateConcentratorPageTest.cs +++ b/src/AzureIoTHub.Portal.Server.Tests.Unit/Pages/LoRaWan/Concentrator/CreateConcentratorPageTest.cs @@ -20,6 +20,7 @@ namespace AzureIoTHub.Portal.Server.Tests.Unit.Pages.LoRaWan.Concentrator using MudBlazor; using MudBlazor.Services; using NUnit.Framework; + using AzureIoTHub.Portal.Shared.Models.v10.LoRaWAN; [TestFixture] public class CreateConcentratorPageTest : BlazorUnitTest @@ -59,6 +60,12 @@ public void ClickOnSaveShouldPostConcentratorDetails() LoraRegion = "CN_470_510_RP2" }; + _ = this.mockLoRaWanConcentratorsClientService.Setup(service => service.GetFrequencyPlans()) + .ReturnsAsync(new[] + { + new FrequencyPlan() + }); + _ = this.mockLoRaWanConcentratorsClientService.Setup(service => service.CreateConcentrator(It.Is(concentrator => mockConcentrator.DeviceId.Equals(concentrator.DeviceId, StringComparison.Ordinal)))) @@ -93,6 +100,12 @@ public void ClickOnSaveShouldProcessProblemDetailsExceptionWhenIssueOccursOnCrea LoraRegion = "CN_470_510_RP2" }; + _ = this.mockLoRaWanConcentratorsClientService.Setup(service => service.GetFrequencyPlans()) + .ReturnsAsync(new[] + { + new FrequencyPlan() + }); + _ = this.mockLoRaWanConcentratorsClientService.Setup(service => service.CreateConcentrator(It.Is(concentrator => mockConcentrator.DeviceId.Equals(concentrator.DeviceId, StringComparison.Ordinal)))) @@ -118,6 +131,11 @@ public void ClickOnSaveShouldNotCreateConcentratorWhenModelIsNotValid() { // Arrange _ = this.mockSnackbarService.Setup(c => c.Add(It.IsAny(), Severity.Error, null)).Returns((Snackbar)null); + _ = this.mockLoRaWanConcentratorsClientService.Setup(service => service.GetFrequencyPlans()) + .ReturnsAsync(new[] + { + new FrequencyPlan() + }); var cut = RenderComponent(); cut.WaitForAssertion(() => cut.Find("#saveButton")); diff --git a/src/AzureIoTHub.Portal.Server.Tests.Unit/Services/LoRaWanConcentratorsClientServiceTests.cs b/src/AzureIoTHub.Portal.Server.Tests.Unit/Services/LoRaWanConcentratorsClientServiceTests.cs index 5b3a6c366..fb1b285cc 100644 --- a/src/AzureIoTHub.Portal.Server.Tests.Unit/Services/LoRaWanConcentratorsClientServiceTests.cs +++ b/src/AzureIoTHub.Portal.Server.Tests.Unit/Services/LoRaWanConcentratorsClientServiceTests.cs @@ -9,6 +9,7 @@ namespace AzureIoTHub.Portal.Server.Tests.Unit.Services using System.Threading.Tasks; using AutoFixture; using AzureIoTHub.Portal.Client.Services; + using AzureIoTHub.Portal.Shared.Models.v10.LoRaWAN; using FluentAssertions; using Helpers; using Microsoft.Extensions.DependencyInjection; @@ -140,5 +141,28 @@ public async Task DeleteConcentratorShouldDeleteConcentrator() MockHttpClient.VerifyNoOutstandingRequest(); MockHttpClient.VerifyNoOutstandingExpectation(); } + + [Test] + public async Task GetLoRaWANFrequencyPlansShouldQueryTheController() + { + // Arrange + var expectedFrequencyPlans = new [] + { + new FrequencyPlan(), + new FrequencyPlan(), + new FrequencyPlan() + }; + + _ = MockHttpClient.When(HttpMethod.Get, "/api/lorawan/freqencyplans") + .RespondJson(expectedFrequencyPlans); + + // Act + var result = await this.loRaWanConcentratorsClientService.GetFrequencyPlans(); + + // Assert + _ = result.Should().BeEquivalentTo(expectedFrequencyPlans); + MockHttpClient.VerifyNoOutstandingRequest(); + MockHttpClient.VerifyNoOutstandingExpectation(); + } } } diff --git a/src/AzureIoTHub.Portal/Client/Pages/LoRaWAN/Concentrator/ConcentratorDetailPage.razor b/src/AzureIoTHub.Portal/Client/Pages/LoRaWAN/Concentrator/ConcentratorDetailPage.razor index a24a1e959..195791f15 100644 --- a/src/AzureIoTHub.Portal/Client/Pages/LoRaWAN/Concentrator/ConcentratorDetailPage.razor +++ b/src/AzureIoTHub.Portal/Client/Pages/LoRaWAN/Concentrator/ConcentratorDetailPage.razor @@ -1,6 +1,7 @@ @page "/lorawan/concentrators/{DeviceID}" @using AzureIoTHub.Portal.Client.Validators @using AzureIoTHub.Portal.Models.v10.LoRaWAN +@using AzureIoTHub.Portal.Shared.Models.v10.LoRaWAN @attribute [Authorize] @inject IDialogService DialogService @@ -9,10 +10,10 @@ @inject ILoRaWanConcentratorsClientService LoRaWanConcentratorsClientService - + - LoRaWAN Concentrator + LoRaWAN Concentrator @@ -22,8 +23,8 @@ @(string.IsNullOrEmpty(concentrator.DeviceName) ? concentrator.DeviceId : concentrator.DeviceName) - - + + @if (concentrator.IsConnected) { @@ -75,19 +76,10 @@ - Asia 923-925 MHz, Group 1 - Asia 923-925 MHz, Group 2 - Asia 923-925 MHz, Group 3 - Europe 863-870 MHz - China 470-510 MHz, RP 1 - China 470-510 MHz, RP 2 - United States 902-928 MHz, FSB 1 - United States 902-928 MHz, FSB 2 - United States 902-928 MHz, FSB 3 - United States 902-928 MHz, FSB 4 - United States 902-928 MHz, FSB 5 - United States 902-928 MHz, FSB 6 - United States 902-928 MHz, FSB 7 + @foreach (var frequencyPlan in FrequencyPlans.OrderBy(c => c.Name)) + { + @frequencyPlan.Name + } @@ -119,25 +111,26 @@ @code { [CascadingParameter] - public Error Error {get; set;} - + public Error Error { get; set; } + [Parameter] public string DeviceID { get; set; } private Concentrator concentrator { get; set; } = new(); private MudForm form; private ConcentratorValidator concentratorValidator = new(); + private List FrequencyPlans = new List(); private bool isProcessing; private void Return() => NavigationManager.NavigateTo("/lorawan/concentrators"); public PatternMask maskThumbprint = new PatternMask("XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX") - { + { MaskChars = new[] { new MaskChar('X', @"[0-9a-fA-F]") }, CleanDelimiters = false, Transformation = AllUpperCase - }; + }; private static char AllUpperCase(char c) => c.ToString().ToUpperInvariant()[0]; @@ -146,7 +139,7 @@ try { isProcessing = true; - // Sends a GET request to the DevicesController, to retrieve the specific device from Azure IoT Hub + this.FrequencyPlans.AddRange(await this.LoRaWanConcentratorsClientService.GetFrequencyPlans()); concentrator = await LoRaWanConcentratorsClientService.GetConcentrator(DeviceID); } catch (ProblemDetailsException exception) @@ -204,7 +197,7 @@ { isProcessing = true; - var parameters = new DialogParameters {{"deviceId", concentrator.DeviceId}}; + var parameters = new DialogParameters { { "deviceId", concentrator.DeviceId } }; var result = await DialogService.Show("Confirm Deletion", parameters).Result; isProcessing = false; diff --git a/src/AzureIoTHub.Portal/Client/Pages/LoRaWAN/Concentrator/CreateConcentratorPage.razor b/src/AzureIoTHub.Portal/Client/Pages/LoRaWAN/Concentrator/CreateConcentratorPage.razor index 0e3008b12..5dd974652 100644 --- a/src/AzureIoTHub.Portal/Client/Pages/LoRaWAN/Concentrator/CreateConcentratorPage.razor +++ b/src/AzureIoTHub.Portal/Client/Pages/LoRaWAN/Concentrator/CreateConcentratorPage.razor @@ -1,6 +1,7 @@ @page "/lorawan/concentrators/new" @using AzureIoTHub.Portal.Models.v10.LoRaWAN @using AzureIoTHub.Portal.Client.Validators +@using AzureIoTHub.Portal.Shared.Models.v10.LoRaWAN @attribute [Authorize] @inject ISnackbar Snackbar @@ -57,19 +58,10 @@ - Asia 923-925 MHz, Group 1 - Asia 923-925 MHz, Group 2 - Asia 923-925 MHz, Group 3 - Europe 863-870 MHz - China 470-510 MHz, RP 1 - China 470-510 MHz, RP 2 - United States 902-928 MHz, FSB 1 - United States 902-928 MHz, FSB 2 - United States 902-928 MHz, FSB 3 - United States 902-928 MHz, FSB 4 - United States 902-928 MHz, FSB 5 - United States 902-928 MHz, FSB 6 - United States 902-928 MHz, FSB 7 + @foreach (var frequencyPlan in FrequencyPlans.OrderBy(c => c.Name)) + { + @frequencyPlan.Name + } @@ -100,14 +92,16 @@ -@code { + @code { [CascadingParameter] public Error Error {get; set;} - + private Concentrator concentrator = new(); private MudForm form; private ConcentratorValidator concentratorValidator = new(); + private List FrequencyPlans = new List(); + private bool isProcessing; public PatternMask maskThumbprint = new("XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX") @@ -117,6 +111,13 @@ Transformation = AllUpperCase }; + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + this.FrequencyPlans.AddRange(await this.LoRaWanConcentratorsClientService.GetFrequencyPlans()); + } + private static char AllUpperCase(char c) => c.ToString().ToUpperInvariant()[0]; /// diff --git a/src/AzureIoTHub.Portal/Client/Services/ILoRaWanConcentratorsClientService.cs b/src/AzureIoTHub.Portal/Client/Services/ILoRaWanConcentratorsClientService.cs index d6cb8d77b..dc1edd36f 100644 --- a/src/AzureIoTHub.Portal/Client/Services/ILoRaWanConcentratorsClientService.cs +++ b/src/AzureIoTHub.Portal/Client/Services/ILoRaWanConcentratorsClientService.cs @@ -3,7 +3,9 @@ namespace AzureIoTHub.Portal.Client.Services { + using System.Collections.Generic; using System.Threading.Tasks; + using AzureIoTHub.Portal.Shared.Models.v10.LoRaWAN; using Portal.Models.v10.LoRaWAN; public interface ILoRaWanConcentratorsClientService @@ -17,5 +19,7 @@ public interface ILoRaWanConcentratorsClientService Task UpdateConcentrator(Concentrator concentrator); Task DeleteConcentrator(string deviceId); + + Task> GetFrequencyPlans(); } } diff --git a/src/AzureIoTHub.Portal/Client/Services/LoRaWanConcentratorsClientService.cs b/src/AzureIoTHub.Portal/Client/Services/LoRaWanConcentratorsClientService.cs index af6cf62fd..4be4ab19b 100644 --- a/src/AzureIoTHub.Portal/Client/Services/LoRaWanConcentratorsClientService.cs +++ b/src/AzureIoTHub.Portal/Client/Services/LoRaWanConcentratorsClientService.cs @@ -3,9 +3,11 @@ namespace AzureIoTHub.Portal.Client.Services { + using System.Collections.Generic; using System.Net.Http; using System.Net.Http.Json; using System.Threading.Tasks; + using AzureIoTHub.Portal.Shared.Models.v10.LoRaWAN; using Portal.Models.v10.LoRaWAN; public class LoRaWanConcentratorsClientService : ILoRaWanConcentratorsClientService @@ -41,5 +43,10 @@ public Task DeleteConcentrator(string deviceId) { return this.http.DeleteAsync($"api/lorawan/concentrators/{deviceId}"); } + + public Task> GetFrequencyPlans() + { + return this.http.GetFromJsonAsync>("api/lorawan/freqencyplans"); + } } } diff --git a/src/AzureIoTHub.Portal/Server/AzureIoTHub.Portal.Server.csproj b/src/AzureIoTHub.Portal/Server/AzureIoTHub.Portal.Server.csproj index a1ae1c8d6..45919304f 100644 --- a/src/AzureIoTHub.Portal/Server/AzureIoTHub.Portal.Server.csproj +++ b/src/AzureIoTHub.Portal/Server/AzureIoTHub.Portal.Server.csproj @@ -81,9 +81,7 @@ - - - + true diff --git a/src/AzureIoTHub.Portal/Server/Controllers/v1.0/LoRaWAN/LoRaWANFrequencyPlansController.cs b/src/AzureIoTHub.Portal/Server/Controllers/v1.0/LoRaWAN/LoRaWANFrequencyPlansController.cs new file mode 100644 index 000000000..8849b190e --- /dev/null +++ b/src/AzureIoTHub.Portal/Server/Controllers/v1.0/LoRaWAN/LoRaWANFrequencyPlansController.cs @@ -0,0 +1,45 @@ +// Copyright (c) CGI France. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace AzureIoTHub.Portal.Server.Controllers.V10.LoRaWAN +{ + using System.Collections.Generic; + using AzureIoTHub.Portal.Server.Filters; + using AzureIoTHub.Portal.Shared.Models.v10.LoRaWAN; + using Microsoft.AspNetCore.Authorization; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Mvc; + + [Authorize] + [ApiController] + [ApiVersion("1.0")] + [Route("api/lorawan/freqencyplans")] + [ApiExplorerSettings(GroupName = "LoRa WAN")] + [LoRaFeatureActiveFilter] + public class LoRaWANFrequencyPlansController : ControllerBase + { + /// + /// Get LoRaWAN supported frequency plans. + /// + [HttpGet(Name = "GET LoRaWAN Frequency plans")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> GetFrequencyPlans() + { + return this.Ok(new[] { + new FrequencyPlan { FrequencyPlanID = "AS_923_925_1", Name = "Asia 923-925 MHz, Group 1"}, + new FrequencyPlan { FrequencyPlanID = "AS_923_925_2", Name = "Asia 923-925 MHz, Group 2"}, + new FrequencyPlan { FrequencyPlanID = "AS_923_925_3", Name = "Asia 923-925 MHz, Group 3"}, + new FrequencyPlan { FrequencyPlanID = "EU_863_870", Name = "Europe 863-870 MHz"}, + new FrequencyPlan { FrequencyPlanID = "CN_470_510_RP1", Name = "China 470-510 MHz, RP 1"}, + new FrequencyPlan { FrequencyPlanID = "CN_470_510_RP2", Name = "China 470-510 MHz, RP 2"}, + new FrequencyPlan { FrequencyPlanID = "US_902_928_FSB_1", Name = "United States 902-928 MHz, FSB 1"}, + new FrequencyPlan { FrequencyPlanID = "US_902_928_FSB_2", Name = "United States 902-928 MHz, FSB 2"}, + new FrequencyPlan { FrequencyPlanID = "US_902_928_FSB_3", Name = "United States 902-928 MHz, FSB 3"}, + new FrequencyPlan { FrequencyPlanID = "US_902_928_FSB_4", Name = "United States 902-928 MHz, FSB 4"}, + new FrequencyPlan { FrequencyPlanID = "US_902_928_FSB_5", Name = "United States 902-928 MHz, FSB 5"}, + new FrequencyPlan { FrequencyPlanID = "US_902_928_FSB_6", Name = "United States 902-928 MHz, FSB 6"}, + new FrequencyPlan { FrequencyPlanID = "US_902_928_FSB_7", Name = "United States 902-928 MHz, FSB 7"} + }); + } + } +} diff --git a/src/AzureIoTHub.Portal/Shared/Models/v1.0/LoRaWAN/FrequencyPlan.cs b/src/AzureIoTHub.Portal/Shared/Models/v1.0/LoRaWAN/FrequencyPlan.cs new file mode 100644 index 000000000..172833070 --- /dev/null +++ b/src/AzureIoTHub.Portal/Shared/Models/v1.0/LoRaWAN/FrequencyPlan.cs @@ -0,0 +1,12 @@ +// Copyright (c) CGI France. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace AzureIoTHub.Portal.Shared.Models.v10.LoRaWAN +{ + public class FrequencyPlan + { + public string FrequencyPlanID { get; set; } + + public string Name { get; set; } + } +}