From c409a70e3265ff2973687c29e7354d51c876999a Mon Sep 17 00:00:00 2001 From: ben salim <salim.benahben@cgi.com> Date: Mon, 19 Sep 2022 15:09:16 +0200 Subject: [PATCH 01/11] add new entities --- .../Entities/Device.cs | 40 +++++ .../Entities/LorawanDevice.cs | 168 ++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 src/AzureIoTHubPortal.Domain/Entities/Device.cs create mode 100644 src/AzureIoTHubPortal.Domain/Entities/LorawanDevice.cs diff --git a/src/AzureIoTHubPortal.Domain/Entities/Device.cs b/src/AzureIoTHubPortal.Domain/Entities/Device.cs new file mode 100644 index 000000000..9c44a66eb --- /dev/null +++ b/src/AzureIoTHubPortal.Domain/Entities/Device.cs @@ -0,0 +1,40 @@ +// 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.Domain.Entities +{ + using AzureIoTHub.Portal.Domain.Base; + + public class Device : EntityBase + { + /// <summary> + /// The name of the device. + /// </summary> + public string Name { get; set; } + + /// <summary> + /// The model identifier. + /// </summary> + public string DeviceModelId { get; set; } + + /// <summary> + /// <c>true</c> if this instance is connected; otherwise, <c>false</c>. + /// </summary> + public bool IsConnected { get; set; } + + /// <summary> + /// <c>true</c> if this instance is enabled; otherwise, <c>false</c>. + /// </summary> + public bool IsEnabled { get; set; } + + /// <summary> + /// The status updated time. + /// </summary> + public DateTime StatusUpdatedTime { get; set; } + + /// <summary> + /// List of custom device tags and their values. + /// </summary> + public Dictionary<string, string> Tags { get; set; } = new(); + } +} diff --git a/src/AzureIoTHubPortal.Domain/Entities/LorawanDevice.cs b/src/AzureIoTHubPortal.Domain/Entities/LorawanDevice.cs new file mode 100644 index 000000000..d92fd4856 --- /dev/null +++ b/src/AzureIoTHubPortal.Domain/Entities/LorawanDevice.cs @@ -0,0 +1,168 @@ +// 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.Domain.Entities +{ + using AzureIoTHub.Portal.Models.v10.LoRaWAN; + + public class LorawanDevice : Device + { + /// <summary> + /// A value indicating whether the device uses OTAA to authenticate to LoRaWAN Network, otherwise ABP + /// </summary> + public bool UseOTAA { get; set; } + + /// <summary> + /// The OTAA App Key. + /// </summary> + public string AppKey { get; set; } + + /// <summary> + /// The device OTAA Application EUI. + /// </summary> + public string AppEUI { get; set; } + + /// <summary> + /// The ABP AppSKey. + /// </summary> + public string AppSKey { get; set; } + + /// <summary> + /// The ABP NwkSKey. + /// </summary> + public string NwkSKey { get; set; } + + /// <summary> + /// Unique identifier that allows + /// the device to be recognized. + /// </summary> + public string DevAddr { get; set; } + + /// <summary> + /// A value indicating whether the device has already joined the platform. + /// </summary> + public bool AlreadyLoggedInOnce { get; set; } + + /// <summary> + /// The Device Current Datarate, + /// This value will be only reported if you are using Adaptive Data Rate. + /// </summary> + public string DataRate { get; set; } + + /// <summary> + /// The Device Current Transmit Power, + /// This value will be only reported if you are using Adaptive Data Rate. + /// </summary> + public string TxPower { get; set; } + + /// <summary> + /// The Device Current repetition when transmitting. + /// E.g. if set to two, the device will transmit twice his upstream messages. + /// This value will be only reported if you are using Adaptive Data Rate. + /// </summary> + public string NbRep { get; set; } + + /// <summary> + /// The Device Current Rx2Datarate. + /// </summary> + public string ReportedRX2DataRate { get; set; } + + /// <summary> + /// The Device Current RX1DROffset. + /// </summary> + public string ReportedRX1DROffset { get; set; } + + /// <summary> + /// The Device Current RXDelay. + /// </summary> + public string ReportedRXDelay { get; set; } + + /// <summary> + /// The GatewayID of the device. + /// </summary> + public string GatewayID { get; set; } + + /// <summary> + /// A value indicating whether the downlinks are enabled (True if not provided) + /// </summary> + public bool? Downlink { get; set; } + + /// <summary> + /// The LoRa device class. + /// Default is A. + /// </summary> + public ClassType ClassType { get; set; } + + /// <summary> + /// Allows setting the device preferred receive window (RX1 or RX2). + /// The default preferred receive window is 1. + /// </summary> + public int PreferredWindow { get; set; } + + /// <summary> + /// Allows controlling the handling of duplicate messages received by multiple gateways. + /// The default is Drop. + /// </summary> + public DeduplicationMode Deduplication { get; set; } + + /// <summary> + /// Allows setting an offset between received Datarate and retransmit datarate as specified in the LoRa Specifiations. + /// Valid for OTAA devices. + /// If an invalid value is provided the network server will use default value 0. + /// </summary> + public int? RX1DROffset { get; set; } + + /// <summary> + /// Allows setting a custom Datarate for second receive windows. + /// Valid for OTAA devices. + /// If an invalid value is provided the network server will use default value 0 (DR0). + /// </summary> + public int? RX2DataRate { get; set; } + + /// <summary> + /// Allows setting a custom wait time between receiving and transmission as specified in the specification. + /// </summary> + public int? RXDelay { get; set; } + + /// <summary> + /// Allows to disable the relax mode when using ABP. + /// By default relaxed mode is enabled. + /// </summary> + public bool? ABPRelaxMode { get; set; } + + /// <summary> + /// Allows to explicitly specify a frame counter up start value. + /// If the device joins, this value will be used to validate the first frame and initialize the server state for the device. + /// Default is 0. + /// </summary> + public int? FCntUpStart { get; set; } + + /// <summary> + /// Allows to explicitly specify a frame counter down start value. + /// Default is 0. + /// </summary> + public int? FCntDownStart { get; set; } + + /// <summary> + /// Allows to reset the frame counters to the FCntUpStart/FCntDownStart values respectively. + /// Default is 0. + /// </summary> + public int? FCntResetCounter { get; set; } + + /// <summary> + /// Allow the usage of 32bit counters on your device. + /// </summary> + public bool? Supports32BitFCnt { get; set; } + + /// <summary> + /// Allows defining a sliding expiration to the connection between the leaf device and IoT/Edge Hub. + /// The default is none, which causes the connection to not be dropped. + /// </summary> + public int? KeepAliveTimeout { get; set; } + + /// <summary> + /// The sensor decoder API Url. + /// </summary> + public string SensorDecoder { get; set; } + } +} From b7eb9d9574a5d7db97b2f7888515f17f33ffb78c Mon Sep 17 00:00:00 2001 From: ben salim <salim.benahben@cgi.com> Date: Mon, 19 Sep 2022 15:15:39 +0200 Subject: [PATCH 02/11] add repositories --- .../PortalDbContext.cs | 2 ++ .../Repositories/DeviceRepository.cs | 15 +++++++++++++++ .../Repositories/LorawanDeviceRepository.cs | 15 +++++++++++++++ .../Repositories/IDeviceRepository.cs | 11 +++++++++++ .../Repositories/ILorawanDeviceRepository.cs | 11 +++++++++++ 5 files changed, 54 insertions(+) create mode 100644 src/AzureIoTHub.Portal.Infrastructure/Repositories/DeviceRepository.cs create mode 100644 src/AzureIoTHub.Portal.Infrastructure/Repositories/LorawanDeviceRepository.cs create mode 100644 src/AzureIoTHubPortal.Domain/Repositories/IDeviceRepository.cs create mode 100644 src/AzureIoTHubPortal.Domain/Repositories/ILorawanDeviceRepository.cs diff --git a/src/AzureIoTHub.Portal.Infrastructure/PortalDbContext.cs b/src/AzureIoTHub.Portal.Infrastructure/PortalDbContext.cs index adb8bca34..ee82bd6ad 100644 --- a/src/AzureIoTHub.Portal.Infrastructure/PortalDbContext.cs +++ b/src/AzureIoTHub.Portal.Infrastructure/PortalDbContext.cs @@ -16,6 +16,8 @@ public class PortalDbContext : DbContext public DbSet<DeviceTag> DeviceTags { get; set; } public DbSet<DeviceModelCommand> DeviceModelCommands { get; set; } public DbSet<DeviceModel> DeviceModels { get; set; } + public DbSet<Device> Devices { get; set; } + public DbSet<LorawanDevice> LorawanDevices { get; set; } public DbSet<EdgeDeviceModel> EdgeDeviceModels { get; set; } public DbSet<EdgeDeviceModelCommand> EdgeDeviceModelCommands { get; set; } diff --git a/src/AzureIoTHub.Portal.Infrastructure/Repositories/DeviceRepository.cs b/src/AzureIoTHub.Portal.Infrastructure/Repositories/DeviceRepository.cs new file mode 100644 index 000000000..ae814479a --- /dev/null +++ b/src/AzureIoTHub.Portal.Infrastructure/Repositories/DeviceRepository.cs @@ -0,0 +1,15 @@ +// 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.Infrastructure.Repositories +{ + using AzureIoTHub.Portal.Domain.Repositories; + using Domain.Entities; + + public class DeviceRepository : GenericRepository<Device>, IDeviceRepository + { + public DeviceRepository(PortalDbContext context) : base(context) + { + } + } +} diff --git a/src/AzureIoTHub.Portal.Infrastructure/Repositories/LorawanDeviceRepository.cs b/src/AzureIoTHub.Portal.Infrastructure/Repositories/LorawanDeviceRepository.cs new file mode 100644 index 000000000..1ab71dd99 --- /dev/null +++ b/src/AzureIoTHub.Portal.Infrastructure/Repositories/LorawanDeviceRepository.cs @@ -0,0 +1,15 @@ +// 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.Infrastructure.Repositories +{ + using AzureIoTHub.Portal.Domain.Repositories; + using Domain.Entities; + + public class LorawanDeviceRepository : GenericRepository<LorawanDevice>, ILorawanDeviceRepository + { + public LorawanDeviceRepository(PortalDbContext context) : base(context) + { + } + } +} diff --git a/src/AzureIoTHubPortal.Domain/Repositories/IDeviceRepository.cs b/src/AzureIoTHubPortal.Domain/Repositories/IDeviceRepository.cs new file mode 100644 index 000000000..8037dc49b --- /dev/null +++ b/src/AzureIoTHubPortal.Domain/Repositories/IDeviceRepository.cs @@ -0,0 +1,11 @@ +// 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.Domain.Repositories +{ + using Entities; + + public interface IDeviceRepository : IRepository<Device> + { + } +} diff --git a/src/AzureIoTHubPortal.Domain/Repositories/ILorawanDeviceRepository.cs b/src/AzureIoTHubPortal.Domain/Repositories/ILorawanDeviceRepository.cs new file mode 100644 index 000000000..1678577e8 --- /dev/null +++ b/src/AzureIoTHubPortal.Domain/Repositories/ILorawanDeviceRepository.cs @@ -0,0 +1,11 @@ +// 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.Domain.Repositories +{ + using Entities; + + public interface ILorawanDeviceRepository : IRepository<LorawanDevice> + { + } +} From 9266e08a39428e197cd9220c7800ad80d56ae54c Mon Sep 17 00:00:00 2001 From: ben salim <salim.benahben@cgi.com> Date: Mon, 19 Sep 2022 16:07:45 +0200 Subject: [PATCH 03/11] create syncDevice job --- .../Server/Jobs/SyncDevicesJob.cs | 40 +++++++++++++++++++ src/AzureIoTHub.Portal/Server/Startup.cs | 10 +++++ .../Entities/Device.cs | 2 + .../Entities/LorawanDevice.cs | 36 ++++++++++++++++- 4 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 src/AzureIoTHub.Portal/Server/Jobs/SyncDevicesJob.cs diff --git a/src/AzureIoTHub.Portal/Server/Jobs/SyncDevicesJob.cs b/src/AzureIoTHub.Portal/Server/Jobs/SyncDevicesJob.cs new file mode 100644 index 000000000..c267a3dfc --- /dev/null +++ b/src/AzureIoTHub.Portal/Server/Jobs/SyncDevicesJob.cs @@ -0,0 +1,40 @@ +// 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.Jobs +{ + using System.Collections.Generic; + using System.Threading.Tasks; + using AzureIoTHub.Portal.Server.Services; + using Microsoft.Azure.Devices.Shared; + using Quartz; + + public class SyncDevicesJob : IJob + { + private readonly IDeviceService deviceService; + + public SyncDevicesJob(IDeviceService deviceService) + { + this.deviceService = deviceService; + } + + public async Task Execute(IJobExecutionContext context) + { + var twins = new List<Twin>(); + var continuationToken = string.Empty; + + int totalTwinDevices; + do + { + var result = await this.deviceService.GetAllDevice(continuationToken: continuationToken, pageSize: 100); + twins.AddRange(result.Items); + + totalTwinDevices = result.TotalItems; + continuationToken = result.NextPage; + + } while (totalTwinDevices > twins.Count); + + throw new System.NotImplementedException(); + } + } +} diff --git a/src/AzureIoTHub.Portal/Server/Startup.cs b/src/AzureIoTHub.Portal/Server/Startup.cs index f36d0eede..750c6b794 100644 --- a/src/AzureIoTHub.Portal/Server/Startup.cs +++ b/src/AzureIoTHub.Portal/Server/Startup.cs @@ -18,6 +18,7 @@ namespace AzureIoTHub.Portal.Server using AzureIoTHub.Portal.Infrastructure.Factories; using AzureIoTHub.Portal.Infrastructure.Repositories; using AzureIoTHub.Portal.Infrastructure.Seeds; + using AzureIoTHub.Portal.Server.Jobs; using Extensions; using Hellang.Middleware.ProblemDetails; using Hellang.Middleware.ProblemDetails.Mvc; @@ -312,6 +313,15 @@ Specify the authorization token got from your IDP as a header. q.AddMetricsService<DeviceMetricExporterService, DeviceMetricLoaderService>(configuration); q.AddMetricsService<EdgeDeviceMetricExporterService, EdgeDeviceMetricLoaderService>(configuration); q.AddMetricsService<ConcentratorMetricExporterService, ConcentratorMetricLoaderService>(configuration); + + _ = q.AddJob<SyncDevicesJob>(j => j.WithIdentity(nameof(SyncDevicesJob))) + .AddTrigger(t => t + .WithIdentity($"{nameof(SyncDevicesJob)}") + .ForJob(nameof(SyncDevicesJob)) + .WithSimpleSchedule(s => s + //.WithIntervalInSeconds(configuration.MetricExporterRefreshIntervalInSeconds) + .WithIntervalInSeconds(30) + .RepeatForever())); }); diff --git a/src/AzureIoTHubPortal.Domain/Entities/Device.cs b/src/AzureIoTHubPortal.Domain/Entities/Device.cs index 9c44a66eb..775d223a5 100644 --- a/src/AzureIoTHubPortal.Domain/Entities/Device.cs +++ b/src/AzureIoTHubPortal.Domain/Entities/Device.cs @@ -3,6 +3,7 @@ namespace AzureIoTHub.Portal.Domain.Entities { + using System.ComponentModel.DataAnnotations.Schema; using AzureIoTHub.Portal.Domain.Base; public class Device : EntityBase @@ -35,6 +36,7 @@ public class Device : EntityBase /// <summary> /// List of custom device tags and their values. /// </summary> + [Column(TypeName = "jsonb")] public Dictionary<string, string> Tags { get; set; } = new(); } } diff --git a/src/AzureIoTHubPortal.Domain/Entities/LorawanDevice.cs b/src/AzureIoTHubPortal.Domain/Entities/LorawanDevice.cs index d92fd4856..87bfb58a9 100644 --- a/src/AzureIoTHubPortal.Domain/Entities/LorawanDevice.cs +++ b/src/AzureIoTHubPortal.Domain/Entities/LorawanDevice.cs @@ -3,10 +3,44 @@ namespace AzureIoTHub.Portal.Domain.Entities { + using System.ComponentModel.DataAnnotations.Schema; + using AzureIoTHub.Portal.Domain.Base; using AzureIoTHub.Portal.Models.v10.LoRaWAN; - public class LorawanDevice : Device + public class LorawanDevice : EntityBase { + + /// <summary> + /// The name of the device. + /// </summary> + public string Name { get; set; } + + /// <summary> + /// The model identifier. + /// </summary> + public string DeviceModelId { get; set; } + + /// <summary> + /// <c>true</c> if this instance is connected; otherwise, <c>false</c>. + /// </summary> + public bool IsConnected { get; set; } + + /// <summary> + /// <c>true</c> if this instance is enabled; otherwise, <c>false</c>. + /// </summary> + public bool IsEnabled { get; set; } + + /// <summary> + /// The status updated time. + /// </summary> + public DateTime StatusUpdatedTime { get; set; } + + /// <summary> + /// List of custom device tags and their values. + /// </summary> + [Column(TypeName = "jsonb")] + public Dictionary<string, string> Tags { get; set; } = new(); + /// <summary> /// A value indicating whether the device uses OTAA to authenticate to LoRaWAN Network, otherwise ABP /// </summary> From c197f7f1fbe488cd5b4a46f585d164c9c76fac24 Mon Sep 17 00:00:00 2001 From: ben salim <salim.benahben@cgi.com> Date: Wed, 21 Sep 2022 16:45:07 +0200 Subject: [PATCH 04/11] update mapper --- .../Server/Jobs/SyncDevicesJob.cs | 75 ++++++++++++++++++- .../Server/Mappers/DeviceProfile.cs | 68 +++++++++++++++++ src/AzureIoTHub.Portal/Server/Startup.cs | 5 +- .../Entities/LorawanDevice.cs | 26 +++---- 4 files changed, 157 insertions(+), 17 deletions(-) create mode 100644 src/AzureIoTHub.Portal/Server/Mappers/DeviceProfile.cs diff --git a/src/AzureIoTHub.Portal/Server/Jobs/SyncDevicesJob.cs b/src/AzureIoTHub.Portal/Server/Jobs/SyncDevicesJob.cs index c267a3dfc..ac2683a86 100644 --- a/src/AzureIoTHub.Portal/Server/Jobs/SyncDevicesJob.cs +++ b/src/AzureIoTHub.Portal/Server/Jobs/SyncDevicesJob.cs @@ -5,17 +5,37 @@ namespace AzureIoTHub.Portal.Server.Jobs { using System.Collections.Generic; using System.Threading.Tasks; + using AutoMapper; + using AzureIoTHub.Portal.Domain; + using AzureIoTHub.Portal.Domain.Entities; + using AzureIoTHub.Portal.Domain.Repositories; using AzureIoTHub.Portal.Server.Services; using Microsoft.Azure.Devices.Shared; using Quartz; + [DisallowConcurrentExecution] public class SyncDevicesJob : IJob { private readonly IDeviceService deviceService; + private readonly IDeviceModelRepository deviceModelRepository; + private readonly ILorawanDeviceRepository lorawanDeviceRepository; + private readonly IDeviceRepository deviceRepository; + private readonly IMapper mapper; + private readonly IUnitOfWork unitOfWork; - public SyncDevicesJob(IDeviceService deviceService) + public SyncDevicesJob(IDeviceService deviceService, + IDeviceModelRepository deviceModelRepository, + ILorawanDeviceRepository lorawanDeviceRepository, + IDeviceRepository deviceRepository, + IMapper mapper, + IUnitOfWork unitOfWork) { this.deviceService = deviceService; + this.deviceModelRepository = deviceModelRepository; + this.lorawanDeviceRepository = lorawanDeviceRepository; + this.deviceRepository = deviceRepository; + this.mapper = mapper; + this.unitOfWork = unitOfWork; } public async Task Execute(IJobExecutionContext context) @@ -26,7 +46,7 @@ public async Task Execute(IJobExecutionContext context) int totalTwinDevices; do { - var result = await this.deviceService.GetAllDevice(continuationToken: continuationToken, pageSize: 100); + var result = await this.deviceService.GetAllDevice(continuationToken: continuationToken,excludeDeviceType: "LoRa Concentrator", pageSize: 100); twins.AddRange(result.Items); totalTwinDevices = result.TotalItems; @@ -34,7 +54,56 @@ public async Task Execute(IJobExecutionContext context) } while (totalTwinDevices > twins.Count); - throw new System.NotImplementedException(); + foreach (var twin in twins) + { + var model = await this.deviceModelRepository.GetByIdAsync(twin.Tags["modelId"]?.ToString()); + + if (model == null) + { + continue; + } + + if (model.SupportLoRaFeatures) + { + var device = this.mapper.Map<LorawanDevice>(twin); + + var lorawanDeviceEntity = await this.lorawanDeviceRepository.GetByIdAsync(device.Id); + if (lorawanDeviceEntity == null) + { + await this.lorawanDeviceRepository.InsertAsync(device); + } + else + { + if (lorawanDeviceEntity.StatusUpdatedTime < device.StatusUpdatedTime) + { + _ = this.mapper.Map(device, lorawanDeviceEntity); + this.lorawanDeviceRepository.Update(lorawanDeviceEntity); + } + } + } + else + { + var device = this.mapper.Map<Device>(twin); + + var deviceEntity = await this.deviceRepository.GetByIdAsync(device.Id); + if (deviceEntity == null) + { + await this.deviceRepository.InsertAsync(device); + } + else + { + if (deviceEntity.StatusUpdatedTime < device.StatusUpdatedTime) + { + _ = this.mapper.Map(device, deviceEntity); + this.deviceRepository.Update(deviceEntity); + } + } + } + } + + // save entity + await this.unitOfWork.SaveAsync(); + } } } diff --git a/src/AzureIoTHub.Portal/Server/Mappers/DeviceProfile.cs b/src/AzureIoTHub.Portal/Server/Mappers/DeviceProfile.cs new file mode 100644 index 000000000..f0d40fa28 --- /dev/null +++ b/src/AzureIoTHub.Portal/Server/Mappers/DeviceProfile.cs @@ -0,0 +1,68 @@ +// 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.Mappers +{ + using System.Collections.Generic; + using AutoMapper; + using AzureIoTHub.Portal.Domain.Entities; + using AzureIoTHub.Portal.Models.v10.LoRaWAN; + using Microsoft.Azure.Devices.Shared; + + public class DeviceProfile : Profile + { + public DeviceProfile() + { + _ = CreateMap<Twin, Device>() + .ForMember(dest => dest.Id, opts => opts.MapFrom(src => src.DeviceId)) + .ForMember(dest => dest.Name, opts => opts.MapFrom(src => src.Tags["deviceName"])) + .ForMember(dest => dest.DeviceModelId, opts => opts.MapFrom(src => src.Tags["modelId"])) + .ForMember(dest => dest.IsConnected, opts => opts.MapFrom(src => src.ConnectionState == Microsoft.Azure.Devices.DeviceConnectionState.Connected)) + .ForMember(dest => dest.IsEnabled, opts => opts.MapFrom(src => src.Status == Microsoft.Azure.Devices.DeviceStatus.Enabled)) + .ForMember(dest => dest.Tags, opts => opts.MapFrom(src => GetTags(src))); + + _ = CreateMap<Twin, LorawanDevice>() + .ForMember(dest => dest.Id, opts => opts.MapFrom(src => src.DeviceId)) + .ForMember(dest => dest.Name, opts => opts.MapFrom(src => src.Tags["deviceName"])) + .ForMember(dest => dest.DeviceModelId, opts => opts.MapFrom(src => src.Tags["modelId"])) + .ForMember(dest => dest.IsConnected, opts => opts.MapFrom(src => src.ConnectionState == Microsoft.Azure.Devices.DeviceConnectionState.Connected)) + .ForMember(dest => dest.IsEnabled, opts => opts.MapFrom(src => src.Status == Microsoft.Azure.Devices.DeviceStatus.Enabled)) + .ForMember(dest => dest.Tags, opts => opts.MapFrom(src => GetTags(src))) + .ForMember(dest => dest.UseOTAA, opts => opts.MapFrom(src => !string.IsNullOrEmpty(Helpers.DeviceHelper.RetrieveDesiredPropertyValue(src, nameof(LoRaDeviceDetails.AppEUI))))) + .ForMember(dest => dest.AppKey, opts => opts.MapFrom(src => Helpers.DeviceHelper.RetrieveDesiredPropertyValue(src, nameof(LoRaDeviceDetails.AppKey)))) + .ForMember(dest => dest.AppEUI, opts => opts.MapFrom(src => Helpers.DeviceHelper.RetrieveDesiredPropertyValue(src, nameof(LoRaDeviceDetails.AppEUI)))) + .ForMember(dest => dest.AppSKey, opts => opts.MapFrom(src => Helpers.DeviceHelper.RetrieveDesiredPropertyValue(src, nameof(LoRaDeviceDetails.AppSKey)))) + .ForMember(dest => dest.NwkSKey, opts => opts.MapFrom(src => Helpers.DeviceHelper.RetrieveDesiredPropertyValue(src, nameof(LoRaDeviceDetails.NwkSKey)))) + .ForMember(dest => dest.DevAddr, opts => opts.MapFrom(src => Helpers.DeviceHelper.RetrieveDesiredPropertyValue(src, nameof(LoRaDeviceDetails.DevAddr)))) + .ForMember(dest => dest.AlreadyLoggedInOnce, opts => opts.MapFrom(src => Helpers.DeviceHelper.RetrieveReportedPropertyValue(src, "DevAddr") != null)) + .ForMember(dest => dest.DataRate, opts => opts.MapFrom(src => Helpers.DeviceHelper.RetrieveReportedPropertyValue(src, nameof(LoRaDeviceDetails.DataRate)))) + .ForMember(dest => dest.TxPower, opts => opts.MapFrom(src => Helpers.DeviceHelper.RetrieveReportedPropertyValue(src, nameof(LoRaDeviceDetails.TxPower)))) + .ForMember(dest => dest.NbRep, opts => opts.MapFrom(src => Helpers.DeviceHelper.RetrieveReportedPropertyValue(src, nameof(LoRaDeviceDetails.NbRep)))) + .ForMember(dest => dest.ReportedRX2DataRate, opts => opts.MapFrom(src => Helpers.DeviceHelper.RetrieveReportedPropertyValue(src, nameof(LoRaDeviceDetails.ReportedRX2DataRate)))) + .ForMember(dest => dest.ReportedRX1DROffset, opts => opts.MapFrom(src => Helpers.DeviceHelper.RetrieveReportedPropertyValue(src, nameof(LoRaDeviceDetails.ReportedRX1DROffset)))) + .ForMember(dest => dest.ReportedRXDelay, opts => opts.MapFrom(src => Helpers.DeviceHelper.RetrieveReportedPropertyValue(src, nameof(LoRaDeviceDetails.ReportedRXDelay)))) + .ForMember(dest => dest.GatewayID, opts => opts.MapFrom(src => Helpers.DeviceHelper.RetrieveReportedPropertyValue(src, nameof(LoRaDeviceDetails.GatewayID)))); + //.ForMember(dest => dest.Downlink, opts => opts.MapFrom(src => bool.TryParse(Helpers.DeviceHelper.RetrieveDesiredPropertyValue(src, nameof(LoRaDeviceDetails.Downlink)), out bool result))); + } + + private static Dictionary<string, string> GetTags(Twin twin) + { + var customTags = new Dictionary<string, string>(); + + if (twin.Tags != null) + { + var tagList = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object>>(twin.Tags.ToJson()); + + foreach (var tag in tagList) + { + if (tag.Key is not "modelId" and not "deviceName") + { + customTags.Add(tag.Key, tag.Value.ToString()); + } + } + } + + return customTags; + } + } +} diff --git a/src/AzureIoTHub.Portal/Server/Startup.cs b/src/AzureIoTHub.Portal/Server/Startup.cs index 750c6b794..3c1aff26f 100644 --- a/src/AzureIoTHub.Portal/Server/Startup.cs +++ b/src/AzureIoTHub.Portal/Server/Startup.cs @@ -151,6 +151,8 @@ public void ConfigureServices(IServiceCollection services) _ = services.AddScoped<IDeviceModelPropertiesRepository, DeviceModelPropertiesRepository>(); _ = services.AddScoped<IDeviceTagRepository, DeviceTagRepository>(); _ = services.AddScoped<IDeviceModelRepository, DeviceModelRepository>(); + _ = services.AddScoped<IDeviceRepository, DeviceRepository>(); + _ = services.AddScoped<ILorawanDeviceRepository, LorawanDeviceRepository>(); _ = services.AddScoped<IDeviceModelCommandRepository, DeviceModelCommandRepository>(); _ = services.AddMudServices(); @@ -276,6 +278,7 @@ Specify the authorization token got from your IDP as a header. mc.AddProfile(new DeviceTagProfile()); mc.AddProfile(new DeviceModelProfile()); mc.AddProfile(new DeviceModelCommandProfile()); + mc.AddProfile(new DeviceProfile()); }); var mapper = mapperConfig.CreateMapper(); @@ -320,7 +323,7 @@ Specify the authorization token got from your IDP as a header. .ForJob(nameof(SyncDevicesJob)) .WithSimpleSchedule(s => s //.WithIntervalInSeconds(configuration.MetricExporterRefreshIntervalInSeconds) - .WithIntervalInSeconds(30) + .WithIntervalInSeconds(3000) .RepeatForever())); }); diff --git a/src/AzureIoTHubPortal.Domain/Entities/LorawanDevice.cs b/src/AzureIoTHubPortal.Domain/Entities/LorawanDevice.cs index 87bfb58a9..da07e206a 100644 --- a/src/AzureIoTHubPortal.Domain/Entities/LorawanDevice.cs +++ b/src/AzureIoTHubPortal.Domain/Entities/LorawanDevice.cs @@ -49,28 +49,28 @@ public class LorawanDevice : EntityBase /// <summary> /// The OTAA App Key. /// </summary> - public string AppKey { get; set; } + public string? AppKey { get; set; } /// <summary> /// The device OTAA Application EUI. /// </summary> - public string AppEUI { get; set; } + public string? AppEUI { get; set; } /// <summary> /// The ABP AppSKey. /// </summary> - public string AppSKey { get; set; } + public string? AppSKey { get; set; } /// <summary> /// The ABP NwkSKey. /// </summary> - public string NwkSKey { get; set; } + public string? NwkSKey { get; set; } /// <summary> /// Unique identifier that allows /// the device to be recognized. /// </summary> - public string DevAddr { get; set; } + public string? DevAddr { get; set; } /// <summary> /// A value indicating whether the device has already joined the platform. @@ -81,40 +81,40 @@ public class LorawanDevice : EntityBase /// The Device Current Datarate, /// This value will be only reported if you are using Adaptive Data Rate. /// </summary> - public string DataRate { get; set; } + public string? DataRate { get; set; } /// <summary> /// The Device Current Transmit Power, /// This value will be only reported if you are using Adaptive Data Rate. /// </summary> - public string TxPower { get; set; } + public string? TxPower { get; set; } /// <summary> /// The Device Current repetition when transmitting. /// E.g. if set to two, the device will transmit twice his upstream messages. /// This value will be only reported if you are using Adaptive Data Rate. /// </summary> - public string NbRep { get; set; } + public string? NbRep { get; set; } /// <summary> /// The Device Current Rx2Datarate. /// </summary> - public string ReportedRX2DataRate { get; set; } + public string? ReportedRX2DataRate { get; set; } /// <summary> /// The Device Current RX1DROffset. /// </summary> - public string ReportedRX1DROffset { get; set; } + public string? ReportedRX1DROffset { get; set; } /// <summary> /// The Device Current RXDelay. /// </summary> - public string ReportedRXDelay { get; set; } + public string? ReportedRXDelay { get; set; } /// <summary> /// The GatewayID of the device. /// </summary> - public string GatewayID { get; set; } + public string? GatewayID { get; set; } /// <summary> /// A value indicating whether the downlinks are enabled (True if not provided) @@ -197,6 +197,6 @@ public class LorawanDevice : EntityBase /// <summary> /// The sensor decoder API Url. /// </summary> - public string SensorDecoder { get; set; } + public string? SensorDecoder { get; set; } } } From e2eaa8b324ed281e8be6d56fad6c5e32d113ba29 Mon Sep 17 00:00:00 2001 From: ben salim <salim.benahben@cgi.com> Date: Thu, 22 Sep 2022 11:42:23 +0200 Subject: [PATCH 05/11] update device job --- .../Server/Jobs/SyncDevicesJob.cs | 4 +-- .../Server/Mappers/DeviceProfile.cs | 35 +++++++++++++++++-- .../Entities/Device.cs | 2 ++ .../Entities/LorawanDevice.cs | 2 ++ 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/AzureIoTHub.Portal/Server/Jobs/SyncDevicesJob.cs b/src/AzureIoTHub.Portal/Server/Jobs/SyncDevicesJob.cs index ac2683a86..b6c6fff0d 100644 --- a/src/AzureIoTHub.Portal/Server/Jobs/SyncDevicesJob.cs +++ b/src/AzureIoTHub.Portal/Server/Jobs/SyncDevicesJob.cs @@ -74,7 +74,7 @@ public async Task Execute(IJobExecutionContext context) } else { - if (lorawanDeviceEntity.StatusUpdatedTime < device.StatusUpdatedTime) + if (lorawanDeviceEntity.Version < device.Version) { _ = this.mapper.Map(device, lorawanDeviceEntity); this.lorawanDeviceRepository.Update(lorawanDeviceEntity); @@ -92,7 +92,7 @@ public async Task Execute(IJobExecutionContext context) } else { - if (deviceEntity.StatusUpdatedTime < device.StatusUpdatedTime) + if (deviceEntity.Version < device.Version) { _ = this.mapper.Map(device, deviceEntity); this.deviceRepository.Update(deviceEntity); diff --git a/src/AzureIoTHub.Portal/Server/Mappers/DeviceProfile.cs b/src/AzureIoTHub.Portal/Server/Mappers/DeviceProfile.cs index f0d40fa28..4a7096d0e 100644 --- a/src/AzureIoTHub.Portal/Server/Mappers/DeviceProfile.cs +++ b/src/AzureIoTHub.Portal/Server/Mappers/DeviceProfile.cs @@ -3,6 +3,7 @@ namespace AzureIoTHub.Portal.Server.Mappers { + using System; using System.Collections.Generic; using AutoMapper; using AzureIoTHub.Portal.Domain.Entities; @@ -17,6 +18,7 @@ public DeviceProfile() .ForMember(dest => dest.Id, opts => opts.MapFrom(src => src.DeviceId)) .ForMember(dest => dest.Name, opts => opts.MapFrom(src => src.Tags["deviceName"])) .ForMember(dest => dest.DeviceModelId, opts => opts.MapFrom(src => src.Tags["modelId"])) + .ForMember(dest => dest.Version, opts => opts.MapFrom(src => src.Version)) .ForMember(dest => dest.IsConnected, opts => opts.MapFrom(src => src.ConnectionState == Microsoft.Azure.Devices.DeviceConnectionState.Connected)) .ForMember(dest => dest.IsEnabled, opts => opts.MapFrom(src => src.Status == Microsoft.Azure.Devices.DeviceStatus.Enabled)) .ForMember(dest => dest.Tags, opts => opts.MapFrom(src => GetTags(src))); @@ -25,6 +27,7 @@ public DeviceProfile() .ForMember(dest => dest.Id, opts => opts.MapFrom(src => src.DeviceId)) .ForMember(dest => dest.Name, opts => opts.MapFrom(src => src.Tags["deviceName"])) .ForMember(dest => dest.DeviceModelId, opts => opts.MapFrom(src => src.Tags["modelId"])) + .ForMember(dest => dest.Version, opts => opts.MapFrom(src => src.Version)) .ForMember(dest => dest.IsConnected, opts => opts.MapFrom(src => src.ConnectionState == Microsoft.Azure.Devices.DeviceConnectionState.Connected)) .ForMember(dest => dest.IsEnabled, opts => opts.MapFrom(src => src.Status == Microsoft.Azure.Devices.DeviceStatus.Enabled)) .ForMember(dest => dest.Tags, opts => opts.MapFrom(src => GetTags(src))) @@ -41,8 +44,21 @@ public DeviceProfile() .ForMember(dest => dest.ReportedRX2DataRate, opts => opts.MapFrom(src => Helpers.DeviceHelper.RetrieveReportedPropertyValue(src, nameof(LoRaDeviceDetails.ReportedRX2DataRate)))) .ForMember(dest => dest.ReportedRX1DROffset, opts => opts.MapFrom(src => Helpers.DeviceHelper.RetrieveReportedPropertyValue(src, nameof(LoRaDeviceDetails.ReportedRX1DROffset)))) .ForMember(dest => dest.ReportedRXDelay, opts => opts.MapFrom(src => Helpers.DeviceHelper.RetrieveReportedPropertyValue(src, nameof(LoRaDeviceDetails.ReportedRXDelay)))) - .ForMember(dest => dest.GatewayID, opts => opts.MapFrom(src => Helpers.DeviceHelper.RetrieveReportedPropertyValue(src, nameof(LoRaDeviceDetails.GatewayID)))); - //.ForMember(dest => dest.Downlink, opts => opts.MapFrom(src => bool.TryParse(Helpers.DeviceHelper.RetrieveDesiredPropertyValue(src, nameof(LoRaDeviceDetails.Downlink)), out bool result))); + .ForMember(dest => dest.GatewayID, opts => opts.MapFrom(src => Helpers.DeviceHelper.RetrieveReportedPropertyValue(src, nameof(LoRaDeviceDetails.GatewayID)))) + .ForMember(dest => dest.Downlink, opts => opts.MapFrom(src => GetDesiredPropertyAsBooleanValue(src, nameof(LoRaDeviceDetails.Downlink)))) + .ForMember(dest => dest.RX1DROffset, opts => opts.MapFrom(src => GetDesiredPropertyAsIntegerValue(src, nameof(LoRaDeviceDetails.RX1DROffset)))) + .ForMember(dest => dest.RX2DataRate, opts => opts.MapFrom(src => GetDesiredPropertyAsIntegerValue(src, nameof(LoRaDeviceDetails.RX2DataRate)))) + .ForMember(dest => dest.RXDelay, opts => opts.MapFrom(src => GetDesiredPropertyAsIntegerValue(src, nameof(LoRaDeviceDetails.RXDelay)))) + .ForMember(dest => dest.ABPRelaxMode, opts => opts.MapFrom(src => GetDesiredPropertyAsBooleanValue(src, nameof(LoRaDeviceDetails.ABPRelaxMode)))) + .ForMember(dest => dest.FCntUpStart, opts => opts.MapFrom(src => GetDesiredPropertyAsIntegerValue(src, nameof(LoRaDeviceDetails.FCntUpStart)))) + .ForMember(dest => dest.FCntDownStart, opts => opts.MapFrom(src => GetDesiredPropertyAsIntegerValue(src, nameof(LoRaDeviceDetails.FCntDownStart)))) + .ForMember(dest => dest.FCntResetCounter, opts => opts.MapFrom(src => GetDesiredPropertyAsIntegerValue(src, nameof(LoRaDeviceDetails.FCntResetCounter)))) + .ForMember(dest => dest.Supports32BitFCnt, opts => opts.MapFrom(src => GetDesiredPropertyAsBooleanValue(src, nameof(LoRaDeviceDetails.Supports32BitFCnt)))) + .ForMember(dest => dest.KeepAliveTimeout, opts => opts.MapFrom(src => GetDesiredPropertyAsIntegerValue(src, nameof(LoRaDeviceDetails.KeepAliveTimeout)))) + .ForMember(dest => dest.SensorDecoder, opts => opts.MapFrom(src => Helpers.DeviceHelper.RetrieveDesiredPropertyValue(src, nameof(LoRaDeviceDetails.SensorDecoder)))) + .ForMember(dest => dest.Deduplication, opts => opts.MapFrom(src => GetDesiredPropertyAsEnum<DeduplicationMode>(src, nameof(LoRaDeviceDetails.Deduplication)))) + .ForMember(dest => dest.PreferredWindow, opts => opts.MapFrom(src => GetDesiredPropertyAsIntegerValue(src, nameof(LoRaDeviceDetails.PreferredWindow)) ?? 0)) + .ForMember(dest => dest.ClassType, opts => opts.MapFrom(src => GetDesiredPropertyAsEnum<ClassType>(src, nameof(LoRaDeviceDetails.ClassType)))); } private static Dictionary<string, string> GetTags(Twin twin) @@ -64,5 +80,20 @@ private static Dictionary<string, string> GetTags(Twin twin) return customTags; } + + private static bool? GetDesiredPropertyAsBooleanValue(Twin twin, string propertyName) + { + return bool.TryParse(Helpers.DeviceHelper.RetrieveDesiredPropertyValue(twin, propertyName), out var boolResult) ? boolResult : null; + } + + private static int? GetDesiredPropertyAsIntegerValue(Twin twin, string propertyName) + { + return int.TryParse(Helpers.DeviceHelper.RetrieveDesiredPropertyValue(twin, propertyName), out var numberStyles) ? numberStyles : null; + } + + private static TEnum GetDesiredPropertyAsEnum<TEnum>(Twin twin, string propertyName) where TEnum : struct, IConvertible + { + return Enum.TryParse<TEnum>(Helpers.DeviceHelper.RetrieveDesiredPropertyValue(twin, propertyName), out var result) ? result : default; + } } } diff --git a/src/AzureIoTHubPortal.Domain/Entities/Device.cs b/src/AzureIoTHubPortal.Domain/Entities/Device.cs index 775d223a5..6c4368af3 100644 --- a/src/AzureIoTHubPortal.Domain/Entities/Device.cs +++ b/src/AzureIoTHubPortal.Domain/Entities/Device.cs @@ -28,6 +28,8 @@ public class Device : EntityBase /// </summary> public bool IsEnabled { get; set; } + public int Version { get; set; } + /// <summary> /// The status updated time. /// </summary> diff --git a/src/AzureIoTHubPortal.Domain/Entities/LorawanDevice.cs b/src/AzureIoTHubPortal.Domain/Entities/LorawanDevice.cs index da07e206a..7577240d5 100644 --- a/src/AzureIoTHubPortal.Domain/Entities/LorawanDevice.cs +++ b/src/AzureIoTHubPortal.Domain/Entities/LorawanDevice.cs @@ -30,6 +30,8 @@ public class LorawanDevice : EntityBase /// </summary> public bool IsEnabled { get; set; } + public int Version { get; set; } + /// <summary> /// The status updated time. /// </summary> From 1a972c2da76eae9ed48ec9351208fec56088a0d2 Mon Sep 17 00:00:00 2001 From: ben salim <salim.benahben@cgi.com> Date: Thu, 22 Sep 2022 13:39:48 +0200 Subject: [PATCH 06/11] resolve #1030 --- .../ConfigHandlerBase.cs | 2 + .../DevelopmentConfigHandler.cs | 2 + ...2_Add Device and LorawanDevice.Designer.cs | 345 ++++++++++++++++++ ...0922094312_Add Device and LorawanDevice.cs | 90 +++++ .../PortalDbContextModelSnapshot.cs | 155 +++++++- .../ProductionConfigHandler.cs | 2 + .../Server/Mappers/DeviceProfile.cs | 3 + src/AzureIoTHub.Portal/Server/Startup.cs | 4 +- src/AzureIoTHubPortal.Domain/ConfigHandler.cs | 2 + 9 files changed, 602 insertions(+), 3 deletions(-) create mode 100644 src/AzureIoTHub.Portal.Infrastructure/Migrations/20220922094312_Add Device and LorawanDevice.Designer.cs create mode 100644 src/AzureIoTHub.Portal.Infrastructure/Migrations/20220922094312_Add Device and LorawanDevice.cs diff --git a/src/AzureIoTHub.Portal.Infrastructure/ConfigHandlerBase.cs b/src/AzureIoTHub.Portal.Infrastructure/ConfigHandlerBase.cs index efc729b54..044d1d558 100644 --- a/src/AzureIoTHub.Portal.Infrastructure/ConfigHandlerBase.cs +++ b/src/AzureIoTHub.Portal.Infrastructure/ConfigHandlerBase.cs @@ -39,6 +39,8 @@ internal abstract class ConfigHandlerBase : ConfigHandler internal const string MetricExporterRefreshIntervalKey = "Metrics:ExporterRefreshIntervalInSeconds"; internal const string MetricLoaderRefreshIntervalKey = "Metrics:LoaderRefreshIntervalInMinutes"; + internal const string SyncDevicesJobRefreshIntervalKey = "Job:SyncDeviceJobRefreshIntervalInSeconds"; + internal const string IdeasEnabledKey = "Ideas:Enabled"; internal const string IdeasUrlKey = "Ideas:Url"; internal const string IdeasAuthenticationHeaderKey = "Ideas:Authentication:Header"; diff --git a/src/AzureIoTHub.Portal.Infrastructure/DevelopmentConfigHandler.cs b/src/AzureIoTHub.Portal.Infrastructure/DevelopmentConfigHandler.cs index 1b0b52708..2c671a617 100644 --- a/src/AzureIoTHub.Portal.Infrastructure/DevelopmentConfigHandler.cs +++ b/src/AzureIoTHub.Portal.Infrastructure/DevelopmentConfigHandler.cs @@ -16,6 +16,8 @@ internal DevelopmentConfigHandler(IConfiguration config) public override string PortalName => this.config[PortalNameKey]; + public override int SyncDevicesJobRefreshIntervalInSeconds => this.config.GetValue(SyncDevicesJobRefreshIntervalKey, 300); + public override int MetricExporterRefreshIntervalInSeconds => this.config.GetValue(MetricExporterRefreshIntervalKey, 30); public override int MetricLoaderRefreshIntervalInMinutes => this.config.GetValue(MetricLoaderRefreshIntervalKey, 10); diff --git a/src/AzureIoTHub.Portal.Infrastructure/Migrations/20220922094312_Add Device and LorawanDevice.Designer.cs b/src/AzureIoTHub.Portal.Infrastructure/Migrations/20220922094312_Add Device and LorawanDevice.Designer.cs new file mode 100644 index 000000000..4ff1a743c --- /dev/null +++ b/src/AzureIoTHub.Portal.Infrastructure/Migrations/20220922094312_Add Device and LorawanDevice.Designer.cs @@ -0,0 +1,345 @@ +// <auto-generated /> +using System; +using System.Collections.Generic; +using AzureIoTHub.Portal.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace AzureIoTHub.Portal.Infrastructure.Migrations +{ + [DbContext(typeof(PortalDbContext))] + [Migration("20220922094312_Add Device and LorawanDevice")] + partial class AddDeviceandLorawanDevice + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AzureIoTHub.Portal.Domain.Entities.Device", b => + { + b.Property<string>("Id") + .HasColumnType("text"); + + b.Property<string>("DeviceModelId") + .IsRequired() + .HasColumnType("text"); + + b.Property<bool>("IsConnected") + .HasColumnType("boolean"); + + b.Property<bool>("IsEnabled") + .HasColumnType("boolean"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTime>("StatusUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property<Dictionary<string, string>>("Tags") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property<int>("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Devices"); + }); + + modelBuilder.Entity("AzureIoTHub.Portal.Domain.Entities.DeviceModel", b => + { + b.Property<string>("Id") + .HasColumnType("text"); + + b.Property<bool?>("ABPRelaxMode") + .HasColumnType("boolean"); + + b.Property<string>("AppEUI") + .HasColumnType("text"); + + b.Property<int?>("Deduplication") + .HasColumnType("integer"); + + b.Property<string>("Description") + .HasColumnType("text"); + + b.Property<bool?>("Downlink") + .HasColumnType("boolean"); + + b.Property<bool>("IsBuiltin") + .HasColumnType("boolean"); + + b.Property<int?>("KeepAliveTimeout") + .HasColumnType("integer"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property<int?>("PreferredWindow") + .HasColumnType("integer"); + + b.Property<int?>("RXDelay") + .HasColumnType("integer"); + + b.Property<string>("SensorDecoder") + .HasColumnType("text"); + + b.Property<bool>("SupportLoRaFeatures") + .HasColumnType("boolean"); + + b.Property<bool?>("UseOTAA") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("DeviceModels"); + }); + + modelBuilder.Entity("AzureIoTHub.Portal.Domain.Entities.DeviceModelCommand", b => + { + b.Property<string>("Id") + .HasColumnType("text"); + + b.Property<bool>("Confirmed") + .HasColumnType("boolean"); + + b.Property<string>("DeviceModelId") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("Frame") + .IsRequired() + .HasColumnType("text"); + + b.Property<bool>("IsBuiltin") + .HasColumnType("boolean"); + + b.Property<int>("Port") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("DeviceModelCommands"); + }); + + modelBuilder.Entity("AzureIoTHub.Portal.Domain.Entities.DeviceModelProperty", b => + { + b.Property<string>("Id") + .HasColumnType("text"); + + b.Property<string>("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property<bool>("IsWritable") + .HasColumnType("boolean"); + + b.Property<string>("ModelId") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property<int>("Order") + .HasColumnType("integer"); + + b.Property<int>("PropertyType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("DeviceModelProperties"); + }); + + modelBuilder.Entity("AzureIoTHub.Portal.Domain.Entities.DeviceTag", b => + { + b.Property<string>("Id") + .HasColumnType("text"); + + b.Property<string>("Label") + .IsRequired() + .HasColumnType("text"); + + b.Property<bool>("Required") + .HasColumnType("boolean"); + + b.Property<bool>("Searchable") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("DeviceTags"); + }); + + modelBuilder.Entity("AzureIoTHub.Portal.Domain.Entities.EdgeDeviceModel", b => + { + b.Property<string>("Id") + .HasColumnType("text"); + + b.Property<string>("Description") + .HasColumnType("text"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("EdgeDeviceModels"); + }); + + modelBuilder.Entity("AzureIoTHub.Portal.Domain.Entities.EdgeDeviceModelCommand", b => + { + b.Property<string>("Id") + .HasColumnType("text"); + + b.Property<string>("EdgeDeviceModelId") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("EdgeDeviceModelCommands"); + }); + + modelBuilder.Entity("AzureIoTHub.Portal.Domain.Entities.LorawanDevice", b => + { + b.Property<string>("Id") + .HasColumnType("text"); + + b.Property<bool?>("ABPRelaxMode") + .HasColumnType("boolean"); + + b.Property<bool>("AlreadyLoggedInOnce") + .HasColumnType("boolean"); + + b.Property<string>("AppEUI") + .HasColumnType("text"); + + b.Property<string>("AppKey") + .HasColumnType("text"); + + b.Property<string>("AppSKey") + .HasColumnType("text"); + + b.Property<int>("ClassType") + .HasColumnType("integer"); + + b.Property<string>("DataRate") + .HasColumnType("text"); + + b.Property<int>("Deduplication") + .HasColumnType("integer"); + + b.Property<string>("DevAddr") + .HasColumnType("text"); + + b.Property<string>("DeviceModelId") + .IsRequired() + .HasColumnType("text"); + + b.Property<bool?>("Downlink") + .HasColumnType("boolean"); + + b.Property<int?>("FCntDownStart") + .HasColumnType("integer"); + + b.Property<int?>("FCntResetCounter") + .HasColumnType("integer"); + + b.Property<int?>("FCntUpStart") + .HasColumnType("integer"); + + b.Property<string>("GatewayID") + .HasColumnType("text"); + + b.Property<bool>("IsConnected") + .HasColumnType("boolean"); + + b.Property<bool>("IsEnabled") + .HasColumnType("boolean"); + + b.Property<int?>("KeepAliveTimeout") + .HasColumnType("integer"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("NbRep") + .HasColumnType("text"); + + b.Property<string>("NwkSKey") + .HasColumnType("text"); + + b.Property<int>("PreferredWindow") + .HasColumnType("integer"); + + b.Property<int?>("RX1DROffset") + .HasColumnType("integer"); + + b.Property<int?>("RX2DataRate") + .HasColumnType("integer"); + + b.Property<int?>("RXDelay") + .HasColumnType("integer"); + + b.Property<string>("ReportedRX1DROffset") + .HasColumnType("text"); + + b.Property<string>("ReportedRX2DataRate") + .HasColumnType("text"); + + b.Property<string>("ReportedRXDelay") + .HasColumnType("text"); + + b.Property<string>("SensorDecoder") + .HasColumnType("text"); + + b.Property<DateTime>("StatusUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property<bool?>("Supports32BitFCnt") + .HasColumnType("boolean"); + + b.Property<Dictionary<string, string>>("Tags") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property<string>("TxPower") + .HasColumnType("text"); + + b.Property<bool>("UseOTAA") + .HasColumnType("boolean"); + + b.Property<int>("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("LorawanDevices"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/AzureIoTHub.Portal.Infrastructure/Migrations/20220922094312_Add Device and LorawanDevice.cs b/src/AzureIoTHub.Portal.Infrastructure/Migrations/20220922094312_Add Device and LorawanDevice.cs new file mode 100644 index 000000000..8158e9abb --- /dev/null +++ b/src/AzureIoTHub.Portal.Infrastructure/Migrations/20220922094312_Add Device and LorawanDevice.cs @@ -0,0 +1,90 @@ +// Copyright (c) CGI France. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#nullable disable + +namespace AzureIoTHub.Portal.Infrastructure.Migrations +{ + using System; + using System.Collections.Generic; + using Microsoft.EntityFrameworkCore.Migrations; + + public partial class AddDeviceandLorawanDevice : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + _ = migrationBuilder.CreateTable( + name: "Devices", + columns: table => new + { + Id = table.Column<string>(type: "text", nullable: false), + Name = table.Column<string>(type: "text", nullable: false), + DeviceModelId = table.Column<string>(type: "text", nullable: false), + IsConnected = table.Column<bool>(type: "boolean", nullable: false), + IsEnabled = table.Column<bool>(type: "boolean", nullable: false), + Version = table.Column<int>(type: "integer", nullable: false), + StatusUpdatedTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false), + Tags = table.Column<Dictionary<string, string>>(type: "jsonb", nullable: false) + }, + constraints: table => + { + _ = table.PrimaryKey("PK_Devices", x => x.Id); + }); + + _ = migrationBuilder.CreateTable( + name: "LorawanDevices", + columns: table => new + { + Id = table.Column<string>(type: "text", nullable: false), + Name = table.Column<string>(type: "text", nullable: false), + DeviceModelId = table.Column<string>(type: "text", nullable: false), + IsConnected = table.Column<bool>(type: "boolean", nullable: false), + IsEnabled = table.Column<bool>(type: "boolean", nullable: false), + Version = table.Column<int>(type: "integer", nullable: false), + StatusUpdatedTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false), + Tags = table.Column<Dictionary<string, string>>(type: "jsonb", nullable: false), + UseOTAA = table.Column<bool>(type: "boolean", nullable: false), + AppKey = table.Column<string>(type: "text", nullable: true), + AppEUI = table.Column<string>(type: "text", nullable: true), + AppSKey = table.Column<string>(type: "text", nullable: true), + NwkSKey = table.Column<string>(type: "text", nullable: true), + DevAddr = table.Column<string>(type: "text", nullable: true), + AlreadyLoggedInOnce = table.Column<bool>(type: "boolean", nullable: false), + DataRate = table.Column<string>(type: "text", nullable: true), + TxPower = table.Column<string>(type: "text", nullable: true), + NbRep = table.Column<string>(type: "text", nullable: true), + ReportedRX2DataRate = table.Column<string>(type: "text", nullable: true), + ReportedRX1DROffset = table.Column<string>(type: "text", nullable: true), + ReportedRXDelay = table.Column<string>(type: "text", nullable: true), + GatewayID = table.Column<string>(type: "text", nullable: true), + Downlink = table.Column<bool>(type: "boolean", nullable: true), + ClassType = table.Column<int>(type: "integer", nullable: false), + PreferredWindow = table.Column<int>(type: "integer", nullable: false), + Deduplication = table.Column<int>(type: "integer", nullable: false), + RX1DROffset = table.Column<int>(type: "integer", nullable: true), + RX2DataRate = table.Column<int>(type: "integer", nullable: true), + RXDelay = table.Column<int>(type: "integer", nullable: true), + ABPRelaxMode = table.Column<bool>(type: "boolean", nullable: true), + FCntUpStart = table.Column<int>(type: "integer", nullable: true), + FCntDownStart = table.Column<int>(type: "integer", nullable: true), + FCntResetCounter = table.Column<int>(type: "integer", nullable: true), + Supports32BitFCnt = table.Column<bool>(type: "boolean", nullable: true), + KeepAliveTimeout = table.Column<int>(type: "integer", nullable: true), + SensorDecoder = table.Column<string>(type: "text", nullable: true) + }, + constraints: table => + { + _ = table.PrimaryKey("PK_LorawanDevices", x => x.Id); + }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + _ = migrationBuilder.DropTable( + name: "Devices"); + + _ = migrationBuilder.DropTable( + name: "LorawanDevices"); + } + } +} diff --git a/src/AzureIoTHub.Portal.Infrastructure/Migrations/PortalDbContextModelSnapshot.cs b/src/AzureIoTHub.Portal.Infrastructure/Migrations/PortalDbContextModelSnapshot.cs index 91c84a2e3..4b6f0f11f 100644 --- a/src/AzureIoTHub.Portal.Infrastructure/Migrations/PortalDbContextModelSnapshot.cs +++ b/src/AzureIoTHub.Portal.Infrastructure/Migrations/PortalDbContextModelSnapshot.cs @@ -1,5 +1,6 @@ // <auto-generated /> using System; +using System.Collections.Generic; using AzureIoTHub.Portal.Infrastructure; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -17,11 +18,45 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "6.0.8") + .HasAnnotation("ProductVersion", "6.0.9") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("AzureIoTHub.Portal.Domain.Entities.Device", b => + { + b.Property<string>("Id") + .HasColumnType("text"); + + b.Property<string>("DeviceModelId") + .IsRequired() + .HasColumnType("text"); + + b.Property<bool>("IsConnected") + .HasColumnType("boolean"); + + b.Property<bool>("IsEnabled") + .HasColumnType("boolean"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTime>("StatusUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property<Dictionary<string, string>>("Tags") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property<int>("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Devices"); + }); + modelBuilder.Entity("AzureIoTHub.Portal.Domain.Entities.DeviceModel", b => { b.Property<string>("Id") @@ -184,6 +219,124 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("EdgeDeviceModelCommands"); }); + + modelBuilder.Entity("AzureIoTHub.Portal.Domain.Entities.LorawanDevice", b => + { + b.Property<string>("Id") + .HasColumnType("text"); + + b.Property<bool?>("ABPRelaxMode") + .HasColumnType("boolean"); + + b.Property<bool>("AlreadyLoggedInOnce") + .HasColumnType("boolean"); + + b.Property<string>("AppEUI") + .HasColumnType("text"); + + b.Property<string>("AppKey") + .HasColumnType("text"); + + b.Property<string>("AppSKey") + .HasColumnType("text"); + + b.Property<int>("ClassType") + .HasColumnType("integer"); + + b.Property<string>("DataRate") + .HasColumnType("text"); + + b.Property<int>("Deduplication") + .HasColumnType("integer"); + + b.Property<string>("DevAddr") + .HasColumnType("text"); + + b.Property<string>("DeviceModelId") + .IsRequired() + .HasColumnType("text"); + + b.Property<bool?>("Downlink") + .HasColumnType("boolean"); + + b.Property<int?>("FCntDownStart") + .HasColumnType("integer"); + + b.Property<int?>("FCntResetCounter") + .HasColumnType("integer"); + + b.Property<int?>("FCntUpStart") + .HasColumnType("integer"); + + b.Property<string>("GatewayID") + .HasColumnType("text"); + + b.Property<bool>("IsConnected") + .HasColumnType("boolean"); + + b.Property<bool>("IsEnabled") + .HasColumnType("boolean"); + + b.Property<int?>("KeepAliveTimeout") + .HasColumnType("integer"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("NbRep") + .HasColumnType("text"); + + b.Property<string>("NwkSKey") + .HasColumnType("text"); + + b.Property<int>("PreferredWindow") + .HasColumnType("integer"); + + b.Property<int?>("RX1DROffset") + .HasColumnType("integer"); + + b.Property<int?>("RX2DataRate") + .HasColumnType("integer"); + + b.Property<int?>("RXDelay") + .HasColumnType("integer"); + + b.Property<string>("ReportedRX1DROffset") + .HasColumnType("text"); + + b.Property<string>("ReportedRX2DataRate") + .HasColumnType("text"); + + b.Property<string>("ReportedRXDelay") + .HasColumnType("text"); + + b.Property<string>("SensorDecoder") + .HasColumnType("text"); + + b.Property<DateTime>("StatusUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property<bool?>("Supports32BitFCnt") + .HasColumnType("boolean"); + + b.Property<Dictionary<string, string>>("Tags") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property<string>("TxPower") + .HasColumnType("text"); + + b.Property<bool>("UseOTAA") + .HasColumnType("boolean"); + + b.Property<int>("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("LorawanDevices"); + }); #pragma warning restore 612, 618 } } diff --git a/src/AzureIoTHub.Portal.Infrastructure/ProductionConfigHandler.cs b/src/AzureIoTHub.Portal.Infrastructure/ProductionConfigHandler.cs index b2e423dd2..04235edd2 100644 --- a/src/AzureIoTHub.Portal.Infrastructure/ProductionConfigHandler.cs +++ b/src/AzureIoTHub.Portal.Infrastructure/ProductionConfigHandler.cs @@ -16,6 +16,8 @@ internal ProductionConfigHandler(IConfiguration config) public override string PortalName => this.config[PortalNameKey]; + public override int SyncDevicesJobRefreshIntervalInSeconds => this.config.GetValue(SyncDevicesJobRefreshIntervalKey, 300); + public override int MetricExporterRefreshIntervalInSeconds => this.config.GetValue(MetricExporterRefreshIntervalKey, 30); public override int MetricLoaderRefreshIntervalInMinutes => this.config.GetValue(MetricLoaderRefreshIntervalKey, 10); diff --git a/src/AzureIoTHub.Portal/Server/Mappers/DeviceProfile.cs b/src/AzureIoTHub.Portal/Server/Mappers/DeviceProfile.cs index 4a7096d0e..b470a47c1 100644 --- a/src/AzureIoTHub.Portal/Server/Mappers/DeviceProfile.cs +++ b/src/AzureIoTHub.Portal/Server/Mappers/DeviceProfile.cs @@ -14,6 +14,7 @@ public class DeviceProfile : Profile { public DeviceProfile() { + _ = CreateMap<Device, Device>(); _ = CreateMap<Twin, Device>() .ForMember(dest => dest.Id, opts => opts.MapFrom(src => src.DeviceId)) .ForMember(dest => dest.Name, opts => opts.MapFrom(src => src.Tags["deviceName"])) @@ -23,6 +24,8 @@ public DeviceProfile() .ForMember(dest => dest.IsEnabled, opts => opts.MapFrom(src => src.Status == Microsoft.Azure.Devices.DeviceStatus.Enabled)) .ForMember(dest => dest.Tags, opts => opts.MapFrom(src => GetTags(src))); + + _ = CreateMap<LorawanDevice, LorawanDevice>(); _ = CreateMap<Twin, LorawanDevice>() .ForMember(dest => dest.Id, opts => opts.MapFrom(src => src.DeviceId)) .ForMember(dest => dest.Name, opts => opts.MapFrom(src => src.Tags["deviceName"])) diff --git a/src/AzureIoTHub.Portal/Server/Startup.cs b/src/AzureIoTHub.Portal/Server/Startup.cs index 3c1aff26f..ef918cf72 100644 --- a/src/AzureIoTHub.Portal/Server/Startup.cs +++ b/src/AzureIoTHub.Portal/Server/Startup.cs @@ -322,8 +322,8 @@ Specify the authorization token got from your IDP as a header. .WithIdentity($"{nameof(SyncDevicesJob)}") .ForJob(nameof(SyncDevicesJob)) .WithSimpleSchedule(s => s - //.WithIntervalInSeconds(configuration.MetricExporterRefreshIntervalInSeconds) - .WithIntervalInSeconds(3000) + .WithIntervalInSeconds(configuration.SyncDevicesJobRefreshIntervalInSeconds) + //.WithIntervalInSeconds(3000) .RepeatForever())); }); diff --git a/src/AzureIoTHubPortal.Domain/ConfigHandler.cs b/src/AzureIoTHubPortal.Domain/ConfigHandler.cs index b4cc474bc..fe87e5d04 100644 --- a/src/AzureIoTHubPortal.Domain/ConfigHandler.cs +++ b/src/AzureIoTHubPortal.Domain/ConfigHandler.cs @@ -51,6 +51,8 @@ public abstract class ConfigHandler public abstract string PortalName { get; } + public abstract int SyncDevicesJobRefreshIntervalInSeconds { get; } + public abstract int MetricExporterRefreshIntervalInSeconds { get; } public abstract int MetricLoaderRefreshIntervalInMinutes { get; } From f9f05e8c18de9f4cf5bbce449ab657ce9319d683 Mon Sep 17 00:00:00 2001 From: ben salim <salim.benahben@cgi.com> Date: Thu, 22 Sep 2022 14:49:19 +0200 Subject: [PATCH 07/11] fix unit test --- ...0_Add Device and LorawanDevice.Designer.cs} | 11 +++++------ ...0922124300_Add Device and LorawanDevice.cs} | 5 ++--- .../Migrations/PortalDbContextModelSnapshot.cs | 9 ++++----- .../PortalDbContext.cs | 18 ++++++++++++++++++ .../UnitTests/Bases/BackendUnitTest.cs | 1 + .../Entities/Device.cs | 2 -- .../Entities/LorawanDevice.cs | 2 -- 7 files changed, 30 insertions(+), 18 deletions(-) rename src/AzureIoTHub.Portal.Infrastructure/Migrations/{20220922094312_Add Device and LorawanDevice.Designer.cs => 20220922124300_Add Device and LorawanDevice.Designer.cs} (97%) rename src/AzureIoTHub.Portal.Infrastructure/Migrations/{20220922094312_Add Device and LorawanDevice.cs => 20220922124300_Add Device and LorawanDevice.cs} (95%) diff --git a/src/AzureIoTHub.Portal.Infrastructure/Migrations/20220922094312_Add Device and LorawanDevice.Designer.cs b/src/AzureIoTHub.Portal.Infrastructure/Migrations/20220922124300_Add Device and LorawanDevice.Designer.cs similarity index 97% rename from src/AzureIoTHub.Portal.Infrastructure/Migrations/20220922094312_Add Device and LorawanDevice.Designer.cs rename to src/AzureIoTHub.Portal.Infrastructure/Migrations/20220922124300_Add Device and LorawanDevice.Designer.cs index 4ff1a743c..b2db44d9c 100644 --- a/src/AzureIoTHub.Portal.Infrastructure/Migrations/20220922094312_Add Device and LorawanDevice.Designer.cs +++ b/src/AzureIoTHub.Portal.Infrastructure/Migrations/20220922124300_Add Device and LorawanDevice.Designer.cs @@ -1,6 +1,5 @@ // <auto-generated /> using System; -using System.Collections.Generic; using AzureIoTHub.Portal.Infrastructure; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -13,7 +12,7 @@ namespace AzureIoTHub.Portal.Infrastructure.Migrations { [DbContext(typeof(PortalDbContext))] - [Migration("20220922094312_Add Device and LorawanDevice")] + [Migration("20220922124300_Add Device and LorawanDevice")] partial class AddDeviceandLorawanDevice { protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -47,9 +46,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property<DateTime>("StatusUpdatedTime") .HasColumnType("timestamp with time zone"); - b.Property<Dictionary<string, string>>("Tags") + b.Property<string>("Tags") .IsRequired() - .HasColumnType("jsonb"); + .HasColumnType("text"); b.Property<int>("Version") .HasColumnType("integer"); @@ -322,9 +321,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property<bool?>("Supports32BitFCnt") .HasColumnType("boolean"); - b.Property<Dictionary<string, string>>("Tags") + b.Property<string>("Tags") .IsRequired() - .HasColumnType("jsonb"); + .HasColumnType("text"); b.Property<string>("TxPower") .HasColumnType("text"); diff --git a/src/AzureIoTHub.Portal.Infrastructure/Migrations/20220922094312_Add Device and LorawanDevice.cs b/src/AzureIoTHub.Portal.Infrastructure/Migrations/20220922124300_Add Device and LorawanDevice.cs similarity index 95% rename from src/AzureIoTHub.Portal.Infrastructure/Migrations/20220922094312_Add Device and LorawanDevice.cs rename to src/AzureIoTHub.Portal.Infrastructure/Migrations/20220922124300_Add Device and LorawanDevice.cs index 8158e9abb..b12375418 100644 --- a/src/AzureIoTHub.Portal.Infrastructure/Migrations/20220922094312_Add Device and LorawanDevice.cs +++ b/src/AzureIoTHub.Portal.Infrastructure/Migrations/20220922124300_Add Device and LorawanDevice.cs @@ -6,7 +6,6 @@ namespace AzureIoTHub.Portal.Infrastructure.Migrations { using System; - using System.Collections.Generic; using Microsoft.EntityFrameworkCore.Migrations; public partial class AddDeviceandLorawanDevice : Migration @@ -24,7 +23,7 @@ protected override void Up(MigrationBuilder migrationBuilder) IsEnabled = table.Column<bool>(type: "boolean", nullable: false), Version = table.Column<int>(type: "integer", nullable: false), StatusUpdatedTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false), - Tags = table.Column<Dictionary<string, string>>(type: "jsonb", nullable: false) + Tags = table.Column<string>(type: "text", nullable: false) }, constraints: table => { @@ -42,7 +41,7 @@ protected override void Up(MigrationBuilder migrationBuilder) IsEnabled = table.Column<bool>(type: "boolean", nullable: false), Version = table.Column<int>(type: "integer", nullable: false), StatusUpdatedTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false), - Tags = table.Column<Dictionary<string, string>>(type: "jsonb", nullable: false), + Tags = table.Column<string>(type: "text", nullable: false), UseOTAA = table.Column<bool>(type: "boolean", nullable: false), AppKey = table.Column<string>(type: "text", nullable: true), AppEUI = table.Column<string>(type: "text", nullable: true), diff --git a/src/AzureIoTHub.Portal.Infrastructure/Migrations/PortalDbContextModelSnapshot.cs b/src/AzureIoTHub.Portal.Infrastructure/Migrations/PortalDbContextModelSnapshot.cs index 4b6f0f11f..ab7c30d91 100644 --- a/src/AzureIoTHub.Portal.Infrastructure/Migrations/PortalDbContextModelSnapshot.cs +++ b/src/AzureIoTHub.Portal.Infrastructure/Migrations/PortalDbContextModelSnapshot.cs @@ -1,6 +1,5 @@ // <auto-generated /> using System; -using System.Collections.Generic; using AzureIoTHub.Portal.Infrastructure; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -45,9 +44,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property<DateTime>("StatusUpdatedTime") .HasColumnType("timestamp with time zone"); - b.Property<Dictionary<string, string>>("Tags") + b.Property<string>("Tags") .IsRequired() - .HasColumnType("jsonb"); + .HasColumnType("text"); b.Property<int>("Version") .HasColumnType("integer"); @@ -320,9 +319,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property<bool?>("Supports32BitFCnt") .HasColumnType("boolean"); - b.Property<Dictionary<string, string>>("Tags") + b.Property<string>("Tags") .IsRequired() - .HasColumnType("jsonb"); + .HasColumnType("text"); b.Property<string>("TxPower") .HasColumnType("text"); diff --git a/src/AzureIoTHub.Portal.Infrastructure/PortalDbContext.cs b/src/AzureIoTHub.Portal.Infrastructure/PortalDbContext.cs index ee82bd6ad..1eeefbcb6 100644 --- a/src/AzureIoTHub.Portal.Infrastructure/PortalDbContext.cs +++ b/src/AzureIoTHub.Portal.Infrastructure/PortalDbContext.cs @@ -9,6 +9,7 @@ namespace AzureIoTHub.Portal.Infrastructure using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.Logging; + using Newtonsoft.Json; public class PortalDbContext : DbContext { @@ -27,5 +28,22 @@ public PortalDbContext(DbContextOptions<PortalDbContext> options) : base(options) { } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + _ = modelBuilder.Entity<Device>() + .Property(b => b.Tags) + .HasConversion( + v => JsonConvert.SerializeObject(v), + v => JsonConvert.DeserializeObject<Dictionary<string, string>>(v)); + + _ = modelBuilder.Entity<LorawanDevice>() + .Property(b => b.Tags) + .HasConversion( + v => JsonConvert.SerializeObject(v), + v => JsonConvert.DeserializeObject<Dictionary<string, string>>(v)); + } } } diff --git a/src/AzureIoTHub.Portal.Tests.Unit/UnitTests/Bases/BackendUnitTest.cs b/src/AzureIoTHub.Portal.Tests.Unit/UnitTests/Bases/BackendUnitTest.cs index 0948fa51c..7f44d9343 100644 --- a/src/AzureIoTHub.Portal.Tests.Unit/UnitTests/Bases/BackendUnitTest.cs +++ b/src/AzureIoTHub.Portal.Tests.Unit/UnitTests/Bases/BackendUnitTest.cs @@ -53,6 +53,7 @@ public virtual void Setup() mc.AddProfile(new DeviceTagProfile()); mc.AddProfile(new DeviceModelProfile()); mc.AddProfile(new DeviceModelCommandProfile()); + mc.AddProfile(new DeviceProfile()); }); Mapper = mappingConfig.CreateMapper(); _ = ServiceCollection.AddSingleton(Mapper); diff --git a/src/AzureIoTHubPortal.Domain/Entities/Device.cs b/src/AzureIoTHubPortal.Domain/Entities/Device.cs index 6c4368af3..97b7ccefd 100644 --- a/src/AzureIoTHubPortal.Domain/Entities/Device.cs +++ b/src/AzureIoTHubPortal.Domain/Entities/Device.cs @@ -3,7 +3,6 @@ namespace AzureIoTHub.Portal.Domain.Entities { - using System.ComponentModel.DataAnnotations.Schema; using AzureIoTHub.Portal.Domain.Base; public class Device : EntityBase @@ -38,7 +37,6 @@ public class Device : EntityBase /// <summary> /// List of custom device tags and their values. /// </summary> - [Column(TypeName = "jsonb")] public Dictionary<string, string> Tags { get; set; } = new(); } } diff --git a/src/AzureIoTHubPortal.Domain/Entities/LorawanDevice.cs b/src/AzureIoTHubPortal.Domain/Entities/LorawanDevice.cs index 7577240d5..f1af440d5 100644 --- a/src/AzureIoTHubPortal.Domain/Entities/LorawanDevice.cs +++ b/src/AzureIoTHubPortal.Domain/Entities/LorawanDevice.cs @@ -3,7 +3,6 @@ namespace AzureIoTHub.Portal.Domain.Entities { - using System.ComponentModel.DataAnnotations.Schema; using AzureIoTHub.Portal.Domain.Base; using AzureIoTHub.Portal.Models.v10.LoRaWAN; @@ -40,7 +39,6 @@ public class LorawanDevice : EntityBase /// <summary> /// List of custom device tags and their values. /// </summary> - [Column(TypeName = "jsonb")] public Dictionary<string, string> Tags { get; set; } = new(); /// <summary> From eb498eeb438c35c4106cfffff1670c593993a2f6 Mon Sep 17 00:00:00 2001 From: ben salim <salim.benahben@cgi.com> Date: Thu, 22 Sep 2022 14:55:24 +0200 Subject: [PATCH 08/11] delete useless code --- src/AzureIoTHub.Portal/Server/Startup.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/AzureIoTHub.Portal/Server/Startup.cs b/src/AzureIoTHub.Portal/Server/Startup.cs index ef918cf72..1ae070467 100644 --- a/src/AzureIoTHub.Portal/Server/Startup.cs +++ b/src/AzureIoTHub.Portal/Server/Startup.cs @@ -323,7 +323,6 @@ Specify the authorization token got from your IDP as a header. .ForJob(nameof(SyncDevicesJob)) .WithSimpleSchedule(s => s .WithIntervalInSeconds(configuration.SyncDevicesJobRefreshIntervalInSeconds) - //.WithIntervalInSeconds(3000) .RepeatForever())); }); From cfde81bb43c7c8baddfd2aa4fb7b1ffb33a311b2 Mon Sep 17 00:00:00 2001 From: ben salim <salim.benahben@cgi.com> Date: Thu, 22 Sep 2022 15:37:44 +0200 Subject: [PATCH 09/11] add unit test device repository --- .../Repositories/DeviceRepositoryTest.cs | 43 +++++++++++++++++++ .../LorawanDeviceRepositoryTest.cs | 43 +++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 src/AzureIoTHub.Portal.Tests.Unit/Infrastructure/Repositories/DeviceRepositoryTest.cs create mode 100644 src/AzureIoTHub.Portal.Tests.Unit/Infrastructure/Repositories/LorawanDeviceRepositoryTest.cs diff --git a/src/AzureIoTHub.Portal.Tests.Unit/Infrastructure/Repositories/DeviceRepositoryTest.cs b/src/AzureIoTHub.Portal.Tests.Unit/Infrastructure/Repositories/DeviceRepositoryTest.cs new file mode 100644 index 000000000..412dfff07 --- /dev/null +++ b/src/AzureIoTHub.Portal.Tests.Unit/Infrastructure/Repositories/DeviceRepositoryTest.cs @@ -0,0 +1,43 @@ +// 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.Tests.Unit.Infrastructure.Repositories +{ + using System.Linq; + using System.Threading.Tasks; + using AutoFixture; + using AzureIoTHub.Portal.Domain.Entities; + using AzureIoTHub.Portal.Infrastructure.Repositories; + using AzureIoTHub.Portal.Tests.Unit.UnitTests.Bases; + using FluentAssertions; + using NUnit.Framework; + + public class DeviceRepositoryTest : BackendUnitTest + { + private DeviceRepository deviceRepository; + + public override void Setup() + { + base.Setup(); + + this.deviceRepository = new DeviceRepository(DbContext); + } + + [Test] + public async Task GetAllShouldReturnExpectedDevices() + { + // Arrange + var expectedDevices = Fixture.CreateMany<Device>(5).ToList(); + + await DbContext.AddRangeAsync(expectedDevices); + + _ = await DbContext.SaveChangesAsync(); + + //Act + var result = this.deviceRepository.GetAll().ToList(); + + // Assert + _ = result.Should().BeEquivalentTo(expectedDevices); + } + } +} diff --git a/src/AzureIoTHub.Portal.Tests.Unit/Infrastructure/Repositories/LorawanDeviceRepositoryTest.cs b/src/AzureIoTHub.Portal.Tests.Unit/Infrastructure/Repositories/LorawanDeviceRepositoryTest.cs new file mode 100644 index 000000000..71aaecdae --- /dev/null +++ b/src/AzureIoTHub.Portal.Tests.Unit/Infrastructure/Repositories/LorawanDeviceRepositoryTest.cs @@ -0,0 +1,43 @@ +// 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.Tests.Unit.Infrastructure.Repositories +{ + using AutoFixture; + using System.Threading.Tasks; + using AzureIoTHub.Portal.Infrastructure.Repositories; + using AzureIoTHub.Portal.Tests.Unit.UnitTests.Bases; + using NUnit.Framework; + using AzureIoTHub.Portal.Domain.Entities; + using System.Linq; + using FluentAssertions; + + public class LorawanDeviceRepositoryTest : BackendUnitTest + { + private LorawanDeviceRepository lorawanDeviceRepository; + + public override void Setup() + { + base.Setup(); + + this.lorawanDeviceRepository = new LorawanDeviceRepository(DbContext); + } + + [Test] + public async Task GetAllShouldReturnExpectedDevices() + { + // Arrange + var expectedDevices = Fixture.CreateMany<LorawanDevice>(5).ToList(); + + await DbContext.AddRangeAsync(expectedDevices); + + _ = await DbContext.SaveChangesAsync(); + + //Act + var result = this.lorawanDeviceRepository.GetAll().ToList(); + + // Assert + _ = result.Should().BeEquivalentTo(expectedDevices); + } + } +} From 1dbf16fb8cb500f4d0a622b4354d4dbe691e30a2 Mon Sep 17 00:00:00 2001 From: ben salim <salim.benahben@cgi.com> Date: Thu, 22 Sep 2022 16:04:37 +0200 Subject: [PATCH 10/11] add test on configHandler production/development --- .../Infrastructure/DevelopmentConfigHandlerTests.cs | 10 ++++++++++ .../Infrastructure/ProductionConfigHandlerTests.cs | 12 ++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/AzureIoTHub.Portal.Tests.Unit/Infrastructure/DevelopmentConfigHandlerTests.cs b/src/AzureIoTHub.Portal.Tests.Unit/Infrastructure/DevelopmentConfigHandlerTests.cs index 7d389c50b..7886fbce6 100644 --- a/src/AzureIoTHub.Portal.Tests.Unit/Infrastructure/DevelopmentConfigHandlerTests.cs +++ b/src/AzureIoTHub.Portal.Tests.Unit/Infrastructure/DevelopmentConfigHandlerTests.cs @@ -159,6 +159,16 @@ public void SettingsShouldGetBoolFromAppSettings(string configKey, string config this.mockRepository.VerifyAll(); } + [Test] + public void SyncDevicesJobRefreshIntervalInSecondsConfigMustHaveDefaultValue() + { + // Arrange + var developmentConfigHandler = new DevelopmentConfigHandler(new ConfigurationManager()); + + // Assert + _ = developmentConfigHandler.SyncDevicesJobRefreshIntervalInSeconds.Should().Be(300); + } + [Test] public void MetricExporterRefreshIntervalInSecondsConfigMustHaveDefaultValue() { diff --git a/src/AzureIoTHub.Portal.Tests.Unit/Infrastructure/ProductionConfigHandlerTests.cs b/src/AzureIoTHub.Portal.Tests.Unit/Infrastructure/ProductionConfigHandlerTests.cs index 5b9c09e1f..c52d8884d 100644 --- a/src/AzureIoTHub.Portal.Tests.Unit/Infrastructure/ProductionConfigHandlerTests.cs +++ b/src/AzureIoTHub.Portal.Tests.Unit/Infrastructure/ProductionConfigHandlerTests.cs @@ -3,10 +3,8 @@ namespace AzureIoTHub.Portal.Tests.Unit.Infrastructure { - using AzureIoTHub.Portal.Server; using System; using System.Globalization; - using System.Reflection; using FluentAssertions; using Microsoft.Extensions.Configuration; using Moq; @@ -185,6 +183,16 @@ public void SettingsShouldGetBoolFromAppSettings(string configKey, string config this.mockRepository.VerifyAll(); } + [Test] + public void SyncDevicesJobRefreshIntervalInSecondsConfigMustHaveDefaultValue() + { + // Arrange + var productionConfigHandler = new ProductionConfigHandler(new ConfigurationManager()); + + // Assert + _ = productionConfigHandler.SyncDevicesJobRefreshIntervalInSeconds.Should().Be(300); + } + [Test] public void MetricExporterRefreshIntervalInSecondsConfigMustHaveDefaultValue() { From 77b5759b5eb51da3bd2c81d23c1aa79e81b64cd3 Mon Sep 17 00:00:00 2001 From: ben salim <salim.benahben@cgi.com> Date: Fri, 23 Sep 2022 15:12:03 +0200 Subject: [PATCH 11/11] add test for syncDeviceJob --- .../Server/Jobs/SyncDeviceJobTest.cs | 113 ++++++++++++++++++ .../Server/Jobs/SyncDevicesJob.cs | 67 +++++++---- 2 files changed, 155 insertions(+), 25 deletions(-) create mode 100644 src/AzureIoTHub.Portal.Tests.Unit/Server/Jobs/SyncDeviceJobTest.cs diff --git a/src/AzureIoTHub.Portal.Tests.Unit/Server/Jobs/SyncDeviceJobTest.cs b/src/AzureIoTHub.Portal.Tests.Unit/Server/Jobs/SyncDeviceJobTest.cs new file mode 100644 index 000000000..4874e26b5 --- /dev/null +++ b/src/AzureIoTHub.Portal.Tests.Unit/Server/Jobs/SyncDeviceJobTest.cs @@ -0,0 +1,113 @@ +// 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.Tests.Unit.Server.Jobs +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + using AutoMapper; + using AzureIoTHub.Portal.Domain; + using AzureIoTHub.Portal.Domain.Entities; + using AzureIoTHub.Portal.Domain.Repositories; + using AzureIoTHub.Portal.Server.Jobs; + using AzureIoTHub.Portal.Server.Services; + using Microsoft.Azure.Devices.Shared; + using Microsoft.Extensions.Logging; + using Moq; + using NUnit.Framework; + using Quartz; + + public class SyncDeviceJobTest : IDisposable + { + private MockRepository mockRepository; + private SyncDevicesJob syncDevicesJob; + + private Mock<ILogger<SyncDevicesJob>> mockLogger; + private Mock<IDeviceService> mockDeviceService; + private Mock<IDeviceModelRepository> mockDeviceModelRepository; + private Mock<ILorawanDeviceRepository> mockLorawanDeviceRepository; + private Mock<IDeviceRepository> mockDeviceRepository; + private Mock<IUnitOfWork> mockUnitOfWork; + private Mock<IMapper> mockMapper; + + [SetUp] + public void SetUp() + { + this.mockRepository = new MockRepository(MockBehavior.Strict); + + this.mockLogger = this.mockRepository.Create<ILogger<SyncDevicesJob>>(); + this.mockDeviceService = this.mockRepository.Create<IDeviceService>(); + this.mockDeviceModelRepository = this.mockRepository.Create<IDeviceModelRepository>(); + this.mockLorawanDeviceRepository = this.mockRepository.Create<ILorawanDeviceRepository>(); + this.mockDeviceRepository = this.mockRepository.Create<IDeviceRepository>(); + this.mockUnitOfWork = this.mockRepository.Create<IUnitOfWork>(); + this.mockMapper = this.mockRepository.Create<IMapper>(); + + this.syncDevicesJob = + new SyncDevicesJob( + this.mockDeviceService.Object, + this.mockDeviceModelRepository.Object, + this.mockLorawanDeviceRepository.Object, + this.mockDeviceRepository.Object, + this.mockMapper.Object, + this.mockUnitOfWork.Object, + this.mockLogger.Object); + } + + [Test] + public async Task ExecuteShould() + { + // Arrange + var mockJobExecutionContext = this.mockRepository.Create<IJobExecutionContext>(); + var twinCollection = new TwinCollection(); + + _ = this.mockDeviceService + .Setup(x => x.GetAllDevice( + It.IsAny<string>(), + It.IsAny<string>(), + It.Is<string>(x => x == "LoRa Concentrator"), + It.IsAny<string>(), + It.IsAny<bool?>(), + It.IsAny<bool?>(), + It.IsAny<Dictionary<string, string>>(), + It.Is<int>(x => x == 100))) + .ReturnsAsync(new PaginationResult<Twin> + { + Items = Enumerable.Range(0, 100).Select(x => new Twin + { + DeviceId = FormattableString.Invariant($"{x}"), + Tags = twinCollection + }), + TotalItems = 1000, + NextPage = Guid.NewGuid().ToString() + }); + + + _ = this.mockDeviceModelRepository + .Setup(x => x.GetByIdAsync(It.IsAny<string>())) + .ReturnsAsync(new Portal.Domain.Entities.DeviceModel() + { + Id = Guid.NewGuid().ToString(), + SupportLoRaFeatures = true + }); + + // Act + await this.syncDevicesJob.Execute(mockJobExecutionContext.Object); + + // Assert + this.mockRepository.VerifyAll(); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + } + } +} diff --git a/src/AzureIoTHub.Portal/Server/Jobs/SyncDevicesJob.cs b/src/AzureIoTHub.Portal/Server/Jobs/SyncDevicesJob.cs index b6c6fff0d..f0dbad0c4 100644 --- a/src/AzureIoTHub.Portal/Server/Jobs/SyncDevicesJob.cs +++ b/src/AzureIoTHub.Portal/Server/Jobs/SyncDevicesJob.cs @@ -11,6 +11,7 @@ namespace AzureIoTHub.Portal.Server.Jobs using AzureIoTHub.Portal.Domain.Repositories; using AzureIoTHub.Portal.Server.Services; using Microsoft.Azure.Devices.Shared; + using Microsoft.Extensions.Logging; using Quartz; [DisallowConcurrentExecution] @@ -22,13 +23,15 @@ public class SyncDevicesJob : IJob private readonly IDeviceRepository deviceRepository; private readonly IMapper mapper; private readonly IUnitOfWork unitOfWork; + private readonly ILogger<SyncDevicesJob> logger; public SyncDevicesJob(IDeviceService deviceService, IDeviceModelRepository deviceModelRepository, ILorawanDeviceRepository lorawanDeviceRepository, IDeviceRepository deviceRepository, IMapper mapper, - IUnitOfWork unitOfWork) + IUnitOfWork unitOfWork, + ILogger<SyncDevicesJob> logger) { this.deviceService = deviceService; this.deviceModelRepository = deviceModelRepository; @@ -36,23 +39,12 @@ public SyncDevicesJob(IDeviceService deviceService, this.deviceRepository = deviceRepository; this.mapper = mapper; this.unitOfWork = unitOfWork; + this.logger = logger; } public async Task Execute(IJobExecutionContext context) { - var twins = new List<Twin>(); - var continuationToken = string.Empty; - - int totalTwinDevices; - do - { - var result = await this.deviceService.GetAllDevice(continuationToken: continuationToken,excludeDeviceType: "LoRa Concentrator", pageSize: 100); - twins.AddRange(result.Items); - - totalTwinDevices = result.TotalItems; - continuationToken = result.NextPage; - - } while (totalTwinDevices > twins.Count); + var twins = await GetAllDeviceTwin(); foreach (var twin in twins) { @@ -65,21 +57,28 @@ public async Task Execute(IJobExecutionContext context) if (model.SupportLoRaFeatures) { - var device = this.mapper.Map<LorawanDevice>(twin); - - var lorawanDeviceEntity = await this.lorawanDeviceRepository.GetByIdAsync(device.Id); - if (lorawanDeviceEntity == null) + try { - await this.lorawanDeviceRepository.InsertAsync(device); - } - else - { - if (lorawanDeviceEntity.Version < device.Version) + var device = this.mapper.Map<LorawanDevice>(twin); + + var lorawanDeviceEntity = await this.lorawanDeviceRepository.GetByIdAsync(device.Id); + if (lorawanDeviceEntity == null) + { + await this.lorawanDeviceRepository.InsertAsync(device); + } + else { - _ = this.mapper.Map(device, lorawanDeviceEntity); - this.lorawanDeviceRepository.Update(lorawanDeviceEntity); + if (lorawanDeviceEntity.Version < device.Version) + { + _ = this.mapper.Map(device, lorawanDeviceEntity); + this.lorawanDeviceRepository.Update(lorawanDeviceEntity); + } } } + catch (System.Exception) + { + this.logger.LogWarning($"Error while attenpting to insert LoRa device {twin.DeviceId}."); + } } else { @@ -103,7 +102,25 @@ public async Task Execute(IJobExecutionContext context) // save entity await this.unitOfWork.SaveAsync(); + } + + private async Task<List<Twin>> GetAllDeviceTwin() + { + var twins = new List<Twin>(); + var continuationToken = string.Empty; + + int totalTwinDevices; + do + { + var result = await this.deviceService.GetAllDevice(continuationToken: continuationToken,excludeDeviceType: "LoRa Concentrator", pageSize: 100); + twins.AddRange(result.Items); + + totalTwinDevices = result.TotalItems; + continuationToken = result.NextPage; + + } while (totalTwinDevices > twins.Count); + return twins; } } }