diff --git a/README.md b/README.md index fb263b7c2..01a63370d 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,57 @@ This is currently in **beta** ! The following should be completed before proceeding with the IoT Hub Portal development or deployment in your environment. -- You must have an Azure subscription. Get an [Azure Free account](https://azure.microsoft.com/en-us/offers/ms-azr-0044p/) to get started. +* You must have an Azure subscription. Get an [Azure Free account](https://azure.microsoft.com/en-us/offers/ms-azr-0044p/) to get started. +* You must have configured an Azure AD B2C Tenant with applications. See [Portal AD applications configuration](./b2c-applications.md) page. +* Understandr how IoTEdge LoraWAN StarterKit work. Have a look at [https://azure.github.io/iotedge-lorawan-starterkit](https://azure.github.io/iotedge-lorawan-starterkit) to get started. + +## Quick Start + +### Deployed Azure Resources + +The template will deploy in your Azure subscription the Following resources: + +* IoT Hub +* Azure Function and Consumption Service Plan +* Redis Cache +* Application Insights +* Log Analytics (when opted in to use Azure Monitor) +* Azure WebApp and Service Plan + +### Instructions + +1. Choose a solution prefix for your deployment. +1. Use [Portal AD applications configuration](https://cgi-fr.github.io/IoT-Hub-Portal/docs/b2c-applications.html) page to configure your AD B2C Tenant. + > You should have recorded the following information: + > * Tenant name: `` + > * Tenant ID: `` + > * API Client ID: `` + > * API Client Secret: `` + > * Client ID: `` + +1. Press on the button here below to start your Azure Deployment. + + [![Deploy](http://azuredeploy.net/deploybutton.png)](https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2Fmichelin%2Fi4i-iot-hub-portal%2Fmain%2Ftemplates%2Fazuredeploy.json) + +1. You will get to a page asking you to fill the following fields : + * **Resource Group**: A logical "folder" where all the template resource would be put into, just choose a meaningful name. + * **Location**: In which DataCenter the resources should be deployed. Make sure to choose a location where IoT Hub is available + * **Unique Solution Prefix**: A string that would be used as prefix for all the resources name to ensure their uniqueness. + * **B2c Directory Name**: The name of the B2C directory that will be used to authenticate the portal. + * **Tenant Id**: the ID of the B2C tenant that will be used to authenticate the portal. + * **Api Client Id**: the ID of the API client that will be used to authenticate the portal. + * **Api Client Secret**: the secret of the API client that will be used to authenticate the portal. + * **Client Id**: the ID of the web client that will be used to authenticate the portal. + * **Edge gateway name**: the name of your LoRa Gateway node in the IoT Hub. + * **Deploy Device**: Do you want demo end devices to be already provisioned (one using OTAA and one using ABP)? If yes set this to true, the code located in the Arduino folder would be ready to use immediately. + * **Reset pin**: The reset pin of your gateway (the value should be 7 for the Seed Studio LoRaWan, 25 for the IC880A) + * **Region**: In what region are you operating your device (currently only EU868 and US915 is supported) + + > see: [https://azure.github.io/iotedge-lorawan-starterkit/dev/quickstart/#deployed-azure-infrastructure](https://azure.github.io/iotedge-lorawan-starterkit/dev/quickstart/#deployed-azure-infrastructure) for more information about the LoRaWan IoT Hub and Azure deployment. ## Documentation -Our documentation is present at github page: [https://cgi-fr.github.io/iot-hub-portal/](https://cgi-fr.github.io/iot-hub-portal/). +Our documentation is present at github page: [https://cgi-fr.github.io/IoT-Hub-Portal/](https://cgi-fr.github.io/IoT-Hub-Portal/). ## Known Issues and Limitations diff --git a/src/AzureIoTHub.Portal/Client/Pages/Devices/CreateDevicePage.razor b/src/AzureIoTHub.Portal/Client/Pages/Devices/CreateDevicePage.razor index 786dc5cb3..056e1d6b1 100644 --- a/src/AzureIoTHub.Portal/Client/Pages/Devices/CreateDevicePage.razor +++ b/src/AzureIoTHub.Portal/Client/Pages/Devices/CreateDevicePage.razor @@ -199,14 +199,14 @@ if (response.IsSuccessStatusCode) { Snackbar.Add($"Device {Device.DeviceID} has been successfully created!", Severity.Success); + // Go back to the list of devices + NavManager.NavigateTo("devices"); } else { - Snackbar.Add($"Oh oh, something went wrong while creating device {Device.DeviceID}...", Severity.Error); + Snackbar.Add($"Oh oh, something went wrong while creating device {Device.DeviceID}...
{errorMsg}", Severity.Error); } - // Go back to the list of devices - NavManager.NavigateTo("devices"); } diff --git a/src/AzureIoTHub.Portal/Client/Pages/Devices/DeviceDetailPage.razor b/src/AzureIoTHub.Portal/Client/Pages/Devices/DeviceDetailPage.razor index b12575f6b..4f3f1391e 100644 --- a/src/AzureIoTHub.Portal/Client/Pages/Devices/DeviceDetailPage.razor +++ b/src/AzureIoTHub.Portal/Client/Pages/Devices/DeviceDetailPage.razor @@ -131,10 +131,10 @@ - Last activity time : + Last status update : - + @@ -174,7 +174,7 @@ private DeviceDetails Device { get; set; } = new DeviceDetails(); private bool success = true; - private string LastActivityDateString { get; set; } + private string StatusUpdatedTimeString { get; set; } protected override async Task OnInitializedAsync() { @@ -187,8 +187,8 @@ DeviceModel model = await Http.GetFromJsonAsync($"api/DeviceModels/{Device.ModelId}"); Device.ModelName = model.Name; - // LastActivityDate set to string to have a more human-readable format - LastActivityDateString = Device.LastActivityDate.ToString(); + // StatusUpdatedTime set to string to have a more human-readable format + StatusUpdatedTimeString = Device.StatusUpdatedTime.ToString(); } catch (AccessTokenNotAvailableException exception) { diff --git a/src/AzureIoTHub.Portal/Client/Pages/Devices/DeviceListPage.razor b/src/AzureIoTHub.Portal/Client/Pages/Devices/DeviceListPage.razor index 3cc4abdad..498696264 100644 --- a/src/AzureIoTHub.Portal/Client/Pages/Devices/DeviceListPage.razor +++ b/src/AzureIoTHub.Portal/Client/Pages/Devices/DeviceListPage.razor @@ -103,7 +103,7 @@ Device Status Connection state - Last activity time + Last status update See details Delete @@ -136,7 +136,7 @@ } - @context.LastActivityDate + @context.StatusUpdatedTime diff --git a/src/AzureIoTHub.Portal/Server/Controllers/DevicesController.cs b/src/AzureIoTHub.Portal/Server/Controllers/DevicesController.cs index 7a7a2eb75..769d2c5f5 100644 --- a/src/AzureIoTHub.Portal/Server/Controllers/DevicesController.cs +++ b/src/AzureIoTHub.Portal/Server/Controllers/DevicesController.cs @@ -10,6 +10,7 @@ namespace AzureIoTHub.Portal.Server.Controllers using System.Threading.Tasks; using Azure.Data.Tables; using AzureIoTHub.Portal.Server.Factories; + using AzureIoTHub.Portal.Server.Helpers; using AzureIoTHub.Portal.Server.Managers; using AzureIoTHub.Portal.Server.Mappers; using AzureIoTHub.Portal.Server.Services; @@ -82,6 +83,11 @@ public async Task CreateDeviceAsync(DeviceDetails device) { try { + if (!Eui.TryParse(device.DeviceID, out ulong deviceIdConvert)) + { + throw new InvalidOperationException("the device id is in the wrong format."); + } + // Create a new Twin from the form's fields. var newTwin = new Twin() { @@ -99,7 +105,12 @@ public async Task CreateDeviceAsync(DeviceDetails device) catch (DeviceAlreadyExistsException e) { this.logger.LogError($"{device.DeviceID} - Create device failed", e); - return this.BadRequest(); + return this.BadRequest(e.Message); + } + catch (InvalidOperationException e) + { + this.logger?.LogError("{a0} - Create device failed \n {a1}", device.DeviceID, e.Message); + return this.BadRequest(e.Message); } } diff --git a/src/AzureIoTHub.Portal/Server/Helpers/DeviceHelper.cs b/src/AzureIoTHub.Portal/Server/Helpers/DeviceHelper.cs index 3a1b9a8df..93f4342fd 100644 --- a/src/AzureIoTHub.Portal/Server/Helpers/DeviceHelper.cs +++ b/src/AzureIoTHub.Portal/Server/Helpers/DeviceHelper.cs @@ -162,5 +162,10 @@ public static List RetrieveModuleList(Twin twin, int moduleCount) return list; } } + + public static bool IsValidDevEUI(ulong value) + { + return value is not 0 and not 0xffff_ffff_ffff_ffff; + } } } diff --git a/src/AzureIoTHub.Portal/Server/Helpers/Eui.g.cs b/src/AzureIoTHub.Portal/Server/Helpers/Eui.g.cs new file mode 100644 index 000000000..83ee7446c --- /dev/null +++ b/src/AzureIoTHub.Portal/Server/Helpers/Eui.g.cs @@ -0,0 +1,166 @@ +#nullable enable + +namespace AzureIoTHub.Portal.Server.Helpers +{ + using System; + using System.Buffers.Binary; + + readonly partial record struct DevEui : IFormattable + { + public const int Size = sizeof(ulong); + + readonly ulong value; + + public DevEui(ulong value) => this.value = value; + + public ulong AsUInt64 => this.value; + + public override string ToString() => ToString(null, null); + + public static DevEui Read(ReadOnlySpan buffer) => + new(BinaryPrimitives.ReadUInt64LittleEndian(buffer)); + + public static DevEui Read(ref ReadOnlySpan buffer) + { + var result = Read(buffer); + buffer = buffer[Size..]; + return result; + } + + public Span Write(Span buffer) + { + BinaryPrimitives.WriteUInt64LittleEndian(buffer, this.value); + return buffer[Size..]; + } + + public static DevEui Parse(ReadOnlySpan input) => + TryParse(input, out var result) ? result : throw new FormatException(); + + public static bool TryParse(ReadOnlySpan input, out DevEui result) + { + if (Eui.TryParse(input, out var raw)) + { + result = new DevEui(raw); + return true; + } + else + { + result = default; + return false; + } + } + + public string ToString(string? format, IFormatProvider? formatProvider) => Eui.Format(this.value, format); + + public string ToHex() => ToHex(null); + public string ToHex(LetterCase letterCase) => ToHex(null, letterCase); + public string ToHex(char? separator) => ToHex(separator, LetterCase.Upper); + public string ToHex(char? separator, LetterCase letterCase) => Eui.ToHex(this.value, separator, letterCase); + } + + readonly partial record struct JoinEui : IFormattable + { + public const int Size = sizeof(ulong); + + readonly ulong value; + + public JoinEui(ulong value) => this.value = value; + + public ulong AsUInt64 => this.value; + + public override string ToString() => ToString(null, null); + + public static JoinEui Read(ReadOnlySpan buffer) => + new(BinaryPrimitives.ReadUInt64LittleEndian(buffer)); + + public static JoinEui Read(ref ReadOnlySpan buffer) + { + var result = Read(buffer); + buffer = buffer[Size..]; + return result; + } + + public Span Write(Span buffer) + { + BinaryPrimitives.WriteUInt64LittleEndian(buffer, this.value); + return buffer[Size..]; + } + + public static JoinEui Parse(ReadOnlySpan input) => + TryParse(input, out var result) ? result : throw new FormatException(); + + public static bool TryParse(ReadOnlySpan input, out JoinEui result) + { + if (Eui.TryParse(input, out var raw)) + { + result = new JoinEui(raw); + return true; + } + else + { + result = default; + return false; + } + } + + public string ToString(string? format, IFormatProvider? formatProvider) => Eui.Format(this.value, format); + + public string ToHex() => ToHex(null); + public string ToHex(LetterCase letterCase) => ToHex(null, letterCase); + public string ToHex(char? separator) => ToHex(separator, LetterCase.Upper); + public string ToHex(char? separator, LetterCase letterCase) => Eui.ToHex(this.value, separator, letterCase); + } + + readonly partial record struct StationEui : IFormattable + { + public const int Size = sizeof(ulong); + + readonly ulong value; + + public StationEui(ulong value) => this.value = value; + + public ulong AsUInt64 => this.value; + + public override string ToString() => ToString(null, null); + + public static StationEui Read(ReadOnlySpan buffer) => + new(BinaryPrimitives.ReadUInt64LittleEndian(buffer)); + + public static StationEui Read(ref ReadOnlySpan buffer) + { + var result = Read(buffer); + buffer = buffer[Size..]; + return result; + } + + public Span Write(Span buffer) + { + BinaryPrimitives.WriteUInt64LittleEndian(buffer, this.value); + return buffer[Size..]; + } + + public static StationEui Parse(ReadOnlySpan input) => + TryParse(input, out var result) ? result : throw new FormatException(); + + public static bool TryParse(ReadOnlySpan input, out StationEui result) + { + if (Eui.TryParse(input, out var raw)) + { + result = new StationEui(raw); + return true; + } + else + { + result = default; + return false; + } + } + + public string ToString(string? format, IFormatProvider? formatProvider) => Eui.Format(this.value, format); + + public string ToHex() => ToHex(null); + public string ToHex(LetterCase letterCase) => ToHex(null, letterCase); + public string ToHex(char? separator) => ToHex(separator, LetterCase.Upper); + public string ToHex(char? separator, LetterCase letterCase) => Eui.ToHex(this.value, separator, letterCase); + } +} diff --git a/src/AzureIoTHub.Portal/Server/Helpers/EuiHelper.cs b/src/AzureIoTHub.Portal/Server/Helpers/EuiHelper.cs new file mode 100644 index 000000000..455039219 --- /dev/null +++ b/src/AzureIoTHub.Portal/Server/Helpers/EuiHelper.cs @@ -0,0 +1,126 @@ +// Copyright (c) CGI France. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#nullable enable + +namespace AzureIoTHub.Portal.Server.Helpers +{ + using System; + using System.Buffers.Binary; + + [Flags] + public enum EuiParseOptions + { + None = 0, + + /// + /// Forbids a syntactically correct DevEUI from being parsed for which validation like + /// will return false. + /// + ForbidInvalid = 1, + } + + /// + /// Global end-device ID in IEEE EUI-64 (64-bit Extended Unique Identifier) address space + /// that uniquely identifies the end-device. + /// + /// + /// + /// For OTAA devices, the DevEUI MUST be stored in the end-device before the Join procedure + /// is executed. ABP devices do not need the DevEUI to be stored in the device itself, but + /// it is recommended to do so. + /// + /// EUI are 8 bytes multi-octet fields and are transmitted as little endian. + /// + public partial record struct DevEui + { + public bool IsValid => Eui.IsValid(this.value); + + public static bool TryParse(ReadOnlySpan input, EuiParseOptions options, out DevEui result) + { + if (TryParse(input, out var candidate) + && ((options & EuiParseOptions.ForbidInvalid) == EuiParseOptions.None || candidate.IsValid)) + { + result = candidate; + return true; + } + + result = default; + return false; + } + } + + /// + /// Global application ID in IEEE EUI-64 (64-bit Extended Unique Identifier) address space + /// that uniquely identifies the Join Server that is able to assist in the processing of + /// the Join procedure and the session keys derivation. + /// + /// + /// + /// For OTAA devices, the JoinEUI MUST be stored in the end-device before the Join procedure + /// is executed. The JoinEUI is not required for ABP only end-devices. + /// + /// EUI are 8 bytes multi-octet fields and are transmitted as little endian. + /// + public partial record struct JoinEui { } + + /// + /// ID in IEEE EUI-64 (64-bit Extended Unique Identifier) address space that uniquely identifies + /// a station. + /// + /// + /// EUI are 8 bytes multi-octet fields and are transmitted as little endian. + /// + public partial record struct StationEui + { + public bool IsValid => Eui.IsValid(this.value); + } + + internal static class Eui + { + public static string Format(ulong value, string? format) + { + return format switch + { +#pragma warning disable format + null or "G" or "N" => ToHex(value, LetterCase.Upper), + "d" => ToHex(value, '-', LetterCase.Lower), + "D" => ToHex(value, '-', LetterCase.Upper), + "E" => ToHex(value, ':', LetterCase.Upper), + "e" => ToHex(value, ':', LetterCase.Lower), + "n" or "g" => ToHex(value, LetterCase.Lower), + "I" => Id6.Format(value, Id6.FormatOptions.FixedWidth), + "i" => Id6.Format(value, Id6.FormatOptions.FixedWidth | Id6.FormatOptions.Lowercase), +#pragma warning restore format + _ => throw new FormatException(@"Format string can only be null, ""G"", ""g"", ""D"", ""d"", ""I"", ""i"", ""N"", ""n"", ""E"" or ""e"".") + }; + } + + public static string ToHex(ulong value) => ToHex(value, null); + + public static string ToHex(ulong value, LetterCase letterCase) => ToHex(value, null, letterCase); + + public static string ToHex(ulong value, char? separator) => ToHex(value, separator, LetterCase.Upper); + + public static string ToHex(ulong value, char? separator, LetterCase letterCase) + { + Span bytes = stackalloc byte[sizeof(ulong)]; + BinaryPrimitives.WriteUInt64BigEndian(bytes, value); + var length = separator is null ? bytes.Length * 2 : (bytes.Length * 3) - 1; + var chars = length <= 128 ? stackalloc char[length] : new char[length]; + Hexadecimal.Write(bytes, chars, separator, letterCase); + return new string(chars); + } + + public static bool TryParse(ReadOnlySpan input, out ulong result) => + input.Length switch + { + 23 => Hexadecimal.TryParse(input, out result, '-') // e.g. "88:99:AA:BB:CC:DD:EE:FF" + || Hexadecimal.TryParse(input, out result, ':'), // e.g. "88-99-AA-BB-CC-DD-EE-FF" + 16 => Hexadecimal.TryParse(input, out result), // e.g. "8899AABBCCDDEEFF" + _ => Id6.TryParse(input, out result) // e.g. "8899:AABB:CCDD:EEFF" + }; + + public static bool IsValid(ulong value) => value is not 0 and not 0xffff_ffff_ffff_ffff; + } +} diff --git a/src/AzureIoTHub.Portal/Server/Helpers/Hexadecimal.cs b/src/AzureIoTHub.Portal/Server/Helpers/Hexadecimal.cs new file mode 100644 index 000000000..42728aa74 --- /dev/null +++ b/src/AzureIoTHub.Portal/Server/Helpers/Hexadecimal.cs @@ -0,0 +1,151 @@ +// 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.Helpers +{ + using System; + using System.Buffers.Binary; + using System.Globalization; + + public class Hexadecimal + { + private const string UpperCaseDigits = "0123456789ABCDEF"; + private const string LowerCaseDigits = "0123456789abcdef"; + private const string InsufficientBufferSizeErrorMessage = "Insufficient buffer size to encode hexadecimal."; + + private static void ValidateSufficientlySizedBuffer(int targetCharsLength, int expectedByteSize, string paramName) + { + if (targetCharsLength < expectedByteSize * 2) + throw new ArgumentException(InsufficientBufferSizeErrorMessage, paramName); + } + + public static Span Write(byte value, Span output, LetterCase letterCase = LetterCase.Upper) + { + ValidateSufficientlySizedBuffer(output.Length, sizeof(byte), nameof(output)); + + var digits = letterCase == LetterCase.Lower ? LowerCaseDigits : UpperCaseDigits; + output[0] = digits[value >> 4]; + output[1] = digits[value & 0x0f]; + + return output[2..]; + } + + public static Span Write(ushort value, Span output, LetterCase letterCase = LetterCase.Upper) + { + ValidateSufficientlySizedBuffer(output.Length, sizeof(ushort), nameof(output)); + + unchecked + { + output = Write((byte)(value >> 8), output, letterCase); + output = Write((byte)(value >> 0), output, letterCase); + } + + return output; + } + + public static Span Write(ulong value, Span output, LetterCase letterCase = LetterCase.Upper) + { + ValidateSufficientlySizedBuffer(output.Length, sizeof(ulong), nameof(output)); + + for (var i = sizeof(ulong) - 1; i >= 0; i--) + output = Write(unchecked((byte)(value >> (i << 3))), output, letterCase); + + return output; + } + + public static void Write(ReadOnlySpan buffer, Span output) => Write(buffer, output, null); + + public static void Write(ReadOnlySpan buffer, Span output, LetterCase letterCase) => + Write(buffer, output, null, letterCase); + + public static void Write(ReadOnlySpan buffer, Span output, char? separator) => + Write(buffer, output, separator, LetterCase.Upper); + + public static void Write(ReadOnlySpan buffer, Span output, char? separator, LetterCase letterCase) + { + var length = separator is null ? buffer.Length * 2 : (buffer.Length * 3) - 1; + + if (output.Length < length) + throw new ArgumentException(InsufficientBufferSizeErrorMessage, nameof(output)); + + for (var i = 0; i < buffer.Length; i++) + { + if (i > 0 && separator is { } someSeparator) + { + output[0] = someSeparator; + output = output[1..]; + } + + output = Write(buffer[i], output, letterCase); + } + } + + public static bool TryParse(ReadOnlySpan chars, out ulong value, char? separator = null) + { + value = default; + const int size = sizeof(ulong); + if (chars.IsEmpty || chars.Length != (separator is null ? size * 2 : (size * 3) - 1)) + return false; + Span bytes = stackalloc byte[size]; + if (!TryParse(chars, bytes, separator)) + return false; + value = BinaryPrimitives.ReadUInt64BigEndian(bytes); + return true; + } + + public static bool TryParse(ReadOnlySpan chars, out uint value, char? separator = null) + { + value = default; + const int size = sizeof(uint); + if (chars.IsEmpty || chars.Length != (separator is null ? size * 2 : (size * 3) - 1)) + return false; + Span bytes = stackalloc byte[size]; + if (!TryParse(chars, bytes, separator)) + return false; + value = BinaryPrimitives.ReadUInt32BigEndian(bytes); + return true; + } + + public static bool TryParse(ReadOnlySpan chars, Span output, char? separator = null) + { + static bool IsHexDigit(char ch) => ch is(>= '0' and <= '9') or(>= 'A' and <= 'F') or(>= 'a' and <= 'f'); + + if (chars.IsEmpty) // nothing to do + return true; + + // Fail early for the following cases: + // - not enough source characters + // - first or last character is not a hexadecimal digit + if (chars.Length == 1 || !IsHexDigit(chars[0]) || !IsHexDigit(chars[^1]) || (separator is null && chars.Length % 2 != 0)) + return false; + + // Create a temporary working buffer of the expected size so that we do not put partial + // results into the final output buffer if there is a parsing error partway. + var size = separator is null ? chars.Length / 2 : (chars.Length / 3) + 1; + var temp = size <= 128 ? stackalloc byte[size] : new byte[size]; + + var i = 0; + while (!chars.IsEmpty) + { + if (!byte.TryParse(chars[..2], NumberStyles.AllowHexSpecifier, null, out var b)) + return false; + + temp[i++] = b; + chars = chars[2..]; + + if (!chars.IsEmpty && separator is { } someSeparator) + { + if (chars.Length < 3 || chars[0] != someSeparator) + return false; + chars = chars[1..]; + } + } + + if (output.Length < i) + throw new ArgumentException(InsufficientBufferSizeErrorMessage, nameof(output)); + + temp[..i].CopyTo(output); + return true; + } + } +} diff --git a/src/AzureIoTHub.Portal/Server/Helpers/Id6.cs b/src/AzureIoTHub.Portal/Server/Helpers/Id6.cs new file mode 100644 index 000000000..23fe5e913 --- /dev/null +++ b/src/AzureIoTHub.Portal/Server/Helpers/Id6.cs @@ -0,0 +1,261 @@ +// 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.Helpers +{ + using System; + using System.Buffers.Binary; + + // ID6 + // + // An alternative syntax for representing an EUI, which mimics the encoding rules of IPv6 + // addresses. While IPv6 encoding operates on 128 bits grouped into eight (8) 16-bit blocks, + // ID6 operates on four (4) groups of 16-bit blocks. Below are some examples of ID6 and EUI + // pairs: + // + // - ::0 = 00-00-00-00-00-00-00-00 + // - 1:: = 00-01-00-00-00-00-00-00 + // - ::a:b = 00-00-00-00-00-0a-00-0b + // - f::1 = 00-0f-00-00-00-00-00-01 + // - f:a123:f8:100 = 00-0f-a1-23-00-f8-01-00 + // + // Source: https://doc.sm.tc/station/glossary.html#term-id6 + // Copyright 2019, Semtech Corp + public static class Id6 + { + [Flags] + public enum FormatOptions + { + None = 0, + Lowercase = 1, + FixedWidth = 2, + } + + private enum FormatterState + { + Init, + Word, + WordColonBlank, + Blank, + BlankColonBlank, + ColonColon, + ColonColonWord, + } + + public static string Format(ulong value, FormatOptions options = FormatOptions.None) => + new (Format(value, stackalloc char[(sizeof(ulong) * 2) + 3 /* colons */], options)); + + public static Span Format(ulong value, Span output, FormatOptions options = FormatOptions.None) + { + if (output.Length < 2) + throw new ArgumentException(null, nameof(output)); + + var fixedWidth = (options & FormatOptions.FixedWidth) != 0; + + // bail out early with the 0 == "::" case + if (!fixedWidth && value == 0) + { + output[0] = output[1] = ':'; + return output[..2]; + } + + var casing = (options & FormatOptions.Lowercase) != 0 ? LetterCase.Lower : LetterCase.Upper; + + Span bytes = stackalloc byte[sizeof(ulong)]; + Span chars = stackalloc char[(bytes.Length * 2) + 3 /* colons */]; + BinaryPrimitives.WriteUInt64BigEndian(bytes, value); + + Span word = stackalloc char[sizeof(ushort) * 2]; + var state = FormatterState.Init; + + var i = 0; + for (; !bytes.IsEmpty; bytes = bytes[sizeof(ushort) ..]) + { + bool colon; +#pragma warning disable IDE0072 // Add missing cases (false positive) + (colon, state) = state switch +#pragma warning restore IDE0072 // Add missing cases + { + var s and(FormatterState.Word or FormatterState.Blank or FormatterState.ColonColonWord) => (true, s), + FormatterState.WordColonBlank or FormatterState.BlankColonBlank => (true, FormatterState.ColonColon), + var s => (false, s) + }; + if (colon) + chars[i++] = ':'; + var @short = BinaryPrimitives.ReadUInt16BigEndian(bytes); +#pragma warning disable IDE0058 // Expression value is never used + Hexadecimal.Write(@short, word, casing); +#pragma warning restore IDE0058 // Expression value is never used + var tw = fixedWidth ? word : word.TrimStart('0'); + if (tw.Length != 0) + { + tw.CopyTo(chars[i..]); + i += tw.Length; +#pragma warning disable IDE0072 // Add missing cases (false positive) + state = (state, colon) switch +#pragma warning restore IDE0072 // Add missing cases + { + (FormatterState.Init, _) or(FormatterState.Word or FormatterState.Blank, true) => FormatterState.Word, + (FormatterState.ColonColon, _) or(FormatterState.ColonColonWord, true) => FormatterState.ColonColonWord, + var (s, _) => s, + }; + } + else + { +#pragma warning disable IDE0072 // Add missing cases (false positive) + state = (state, colon) switch +#pragma warning restore IDE0072 // Add missing cases + { + (FormatterState.Init, _) => FormatterState.Blank, + (FormatterState.Word, true) => FormatterState.WordColonBlank, + (FormatterState.Blank, true) => FormatterState.BlankColonBlank, + var (s, _) => s, + }; + } + } + + if (output.Length < i) + throw new ArgumentException(null, nameof(output)); + + chars[..i].CopyTo(output); + return output[..i]; + } + + private struct WordAccumulator + { + private byte digits; + private ushort value; + + public static implicit operator ushort(WordAccumulator acc) => acc.value; + + public void Reset() => (this.digits, this.value) = (0, 0); + + public bool ParseHexDigit(char ch) + { + if (this.digits == 4) + return false; + int d; + switch (ch) + { + case >= '0' and <= '9': d = ch - '0'; break; + case >= 'a' and <= 'f': d = ch - 'a' + 10; break; + case >= 'A' and <= 'F': d = ch - 'A' + 10; break; + default: return false; + } + + this.value = checked((ushort)((this.value << 4) + d)); + this.digits++; + return true; + } + } + + private enum ParserState + { + BeforeColonColon, + ColonBeforeColonColon, + ColonColon, + AfterColonColon, + ColonAfterColonColon, + } + + public static bool TryParse(ReadOnlySpan chars, out ulong result) + { + result = 0; + + if (chars.IsEmpty) + return false; + + var state = ParserState.BeforeColonColon; + Span shorts = stackalloc ushort[4]; + + var si = 0; + var cci = -1; + var wa = default(WordAccumulator); + + for (; !chars.IsEmpty; chars = chars[1..]) + { + var ch = chars[0]; + + restate: + switch (state) + { + case ParserState.BeforeColonColon: + case ParserState.AfterColonColon: + { + if (ch == ':') + { + shorts[si++] = wa; + if (si == 4) + return false; + wa.Reset(); + state = state == ParserState.BeforeColonColon ? ParserState.ColonBeforeColonColon : ParserState.ColonAfterColonColon; + } + else if (!wa.ParseHexDigit(ch)) + { + return false; + } + + break; + } + + case ParserState.ColonBeforeColonColon: + { + if (ch == ':') + { + cci = si; + state = ParserState.ColonColon; + } + else + { + state = ParserState.BeforeColonColon; + goto restate; + } + + break; + } + + case ParserState.ColonAfterColonColon: + { + if (ch == ':') + return false; + state = ParserState.AfterColonColon; + goto restate; + } + + case ParserState.ColonColon: + { + if (!wa.ParseHexDigit(ch)) + return false; + state = ParserState.AfterColonColon; + break; + } + + default: + return false; + } + } + + shorts[si] = wa; + + if (state is ParserState.ColonBeforeColonColon or ParserState.BeforeColonColon && si < 3) + return false; + + ref var a = ref shorts[0]; + ref var b = ref shorts[1]; + ref var c = ref shorts[2]; + ref var d = ref shorts[3]; + + switch (cci, si) + { + case (1, 1): (d, b) = (b, 0); break; + case (1, 2): (d, c, b) = (c, b, 0); break; + case (2, 2): (d, c) = (c, 0); break; + default: break; + } + + result = ((ulong)a << 48) | ((ulong)b << 32) | ((ulong)c << 16) | d; + + return true; + } + } +} diff --git a/src/AzureIoTHub.Portal/Server/Helpers/LetterCase.cs b/src/AzureIoTHub.Portal/Server/Helpers/LetterCase.cs new file mode 100644 index 000000000..57503346b --- /dev/null +++ b/src/AzureIoTHub.Portal/Server/Helpers/LetterCase.cs @@ -0,0 +1,14 @@ +// 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.Helpers +{ + /// + /// Represents upper/lower casing choice for letter. + /// + public enum LetterCase + { + Upper, + Lower + } +} diff --git a/src/AzureIoTHub.Portal/Server/Mappers/DeviceTwinMapper.cs b/src/AzureIoTHub.Portal/Server/Mappers/DeviceTwinMapper.cs index 48f2126ac..c0863d8cf 100644 --- a/src/AzureIoTHub.Portal/Server/Mappers/DeviceTwinMapper.cs +++ b/src/AzureIoTHub.Portal/Server/Mappers/DeviceTwinMapper.cs @@ -37,7 +37,7 @@ public DeviceDetails CreateDeviceDetails(Twin twin) ImageUrl = this.deviceModelImageManager.ComputeImageUri(modelId), IsConnected = twin.ConnectionState == DeviceConnectionState.Connected, IsEnabled = twin.Status == DeviceStatus.Enabled, - LastActivityDate = twin.LastActivityTime.GetValueOrDefault(DateTime.MinValue), + StatusUpdatedTime = twin.StatusUpdatedTime.GetValueOrDefault(DateTime.MinValue), AppEUI = Helpers.DeviceHelper.RetrievePropertyValue(twin, nameof(DeviceDetails.AppEUI)), AppKey = Helpers.DeviceHelper.RetrievePropertyValue(twin, nameof(DeviceDetails.AppKey)), LocationCode = Helpers.DeviceHelper.RetrieveTagValue(twin, nameof(DeviceDetails.LocationCode)), @@ -57,7 +57,7 @@ public DeviceListItem CreateDeviceListItem(Twin twin) IsConnected = twin.ConnectionState == DeviceConnectionState.Connected, IsEnabled = twin.Status == DeviceStatus.Enabled, LocationCode = Helpers.DeviceHelper.RetrieveTagValue(twin, nameof(DeviceDetails.LocationCode)), - LastActivityDate = twin.LastActivityTime.GetValueOrDefault(DateTime.MinValue) + StatusUpdatedTime = twin.StatusUpdatedTime.GetValueOrDefault(DateTime.MinValue) }; } diff --git a/src/AzureIoTHub.Portal/Server/Startup.cs b/src/AzureIoTHub.Portal/Server/Startup.cs index dfa0389ad..6a125a2bc 100644 --- a/src/AzureIoTHub.Portal/Server/Startup.cs +++ b/src/AzureIoTHub.Portal/Server/Startup.cs @@ -169,7 +169,7 @@ internal abstract class ConfigHandler { protected const string IoTHubConnectionStringKey = "IoTHub:ConnectionString"; protected const string DPSConnectionStringKey = "IoTDPS:ConnectionString"; - protected const string DPSDefaultEnrollmentGroupeKey = "IoTDPS:DefaultEnrollmentGroupe"; + protected const string DPSDefaultEnrollmentGroupKey = "IoTDPS:DefaultEnrollmentGroup"; protected const string OIDCScopeKey = "OIDC:Scope"; protected const string OIDCAuthorityKey = "OIDC:Authority"; @@ -198,7 +198,7 @@ internal static ConfigHandler Create(IWebHostEnvironment env, IConfiguration con internal abstract string DPSConnectionString { get; } - internal abstract string DPSDefaultEnrollmentGroupe { get; } + internal abstract string DPSDefaultEnrollmentGroup { get; } internal abstract string StorageAccountConnectionString { get; } @@ -234,7 +234,7 @@ internal ProductionConfigHandler(IConfiguration config) internal override string DPSConnectionString => this.config.GetConnectionString(DPSConnectionStringKey); - internal override string DPSDefaultEnrollmentGroupe => this.config[DPSDefaultEnrollmentGroupeKey]; + internal override string DPSDefaultEnrollmentGroup => this.config[DPSDefaultEnrollmentGroupKey]; internal override string StorageAccountConnectionString => this.config.GetConnectionString(StorageAccountConnectionStringKey); @@ -270,7 +270,7 @@ internal DevelopmentConfigHandler(IConfiguration config) internal override string DPSConnectionString => this.config[DPSConnectionStringKey]; - internal override string DPSDefaultEnrollmentGroupe => this.config[DPSDefaultEnrollmentGroupeKey]; + internal override string DPSDefaultEnrollmentGroup => this.config[DPSDefaultEnrollmentGroupKey]; internal override string StorageAccountConnectionString => this.config[StorageAccountConnectionStringKey]; diff --git a/src/AzureIoTHub.Portal/Shared/Models/Device/DeviceDetails.cs b/src/AzureIoTHub.Portal/Shared/Models/Device/DeviceDetails.cs index 8353955c2..51d6323e3 100644 --- a/src/AzureIoTHub.Portal/Shared/Models/Device/DeviceDetails.cs +++ b/src/AzureIoTHub.Portal/Shared/Models/Device/DeviceDetails.cs @@ -19,7 +19,7 @@ public class DeviceDetails public bool IsEnabled { get; set; } - public DateTime LastActivityDate { get; set; } + public DateTime StatusUpdatedTime { get; set; } public string AppEUI { get; set; } diff --git a/src/AzureIoTHub.Portal/Shared/Models/Device/DeviceListItem.cs b/src/AzureIoTHub.Portal/Shared/Models/Device/DeviceListItem.cs index 42088e6d2..54338f075 100644 --- a/src/AzureIoTHub.Portal/Shared/Models/Device/DeviceListItem.cs +++ b/src/AzureIoTHub.Portal/Shared/Models/Device/DeviceListItem.cs @@ -15,7 +15,7 @@ public class DeviceListItem public bool IsEnabled { get; set; } - public DateTime LastActivityDate { get; set; } + public DateTime StatusUpdatedTime { get; set; } public string AppEUI { get; set; }