diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 2bb30ef..3b42b80 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -22,9 +22,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v2 with: - dotnet-version: | - 6.0.x - 3.1.x + dotnet-version: 6.0.x - name: Restore dependencies run: dotnet restore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f64e86..43ae16d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ jobs: - name: Process trx reports with default if: always() - uses: im-open/process-dotnet-test-results@v2.1.3 + uses: im-open/process-dotnet-test-results@v2.2.1 with: github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index e7a8abc..a647e97 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ *.sln.docstates **/launchSettings.json +.vscode/ # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs diff --git a/MQTTnet.Extensions.MultiCloud.sln b/MQTTnet.Extensions.MultiCloud.sln index 57b5f2e..873b1f4 100644 --- a/MQTTnet.Extensions.MultiCloud.sln +++ b/MQTTnet.Extensions.MultiCloud.sln @@ -20,6 +20,7 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{22965CB2-1455-4938-9783-416B192B79D0}" ProjectSection(SolutionItems) = preProject docs\arch.png = docs\arch.png + docs\aws.md = docs\aws.md docs\ConnectionSettings.md = docs\ConnectionSettings.md docs\feat-matrix.md = docs\feat-matrix.md docs\iotpnp-128.png = docs\iotpnp-128.png @@ -63,6 +64,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "payload-size", "samples\pay EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "memmon-protobuff", "samples\memmon-protobuff\memmon-protobuff.csproj", "{20B75646-CBD2-4E72-8C56-22887A519FA3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "aws-sample", "samples\aws-sample\aws-sample.csproj", "{713F4937-160C-4CA3-9F9B-91DD91E7F5AC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -121,6 +124,10 @@ Global {20B75646-CBD2-4E72-8C56-22887A519FA3}.Debug|Any CPU.Build.0 = Debug|Any CPU {20B75646-CBD2-4E72-8C56-22887A519FA3}.Release|Any CPU.ActiveCfg = Release|Any CPU {20B75646-CBD2-4E72-8C56-22887A519FA3}.Release|Any CPU.Build.0 = Release|Any CPU + {713F4937-160C-4CA3-9F9B-91DD91E7F5AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {713F4937-160C-4CA3-9F9B-91DD91E7F5AC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {713F4937-160C-4CA3-9F9B-91DD91E7F5AC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {713F4937-160C-4CA3-9F9B-91DD91E7F5AC}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -135,6 +142,7 @@ Global {AF019503-8813-4967-B858-0DDAE43C2073} = {F5E59EDE-6E77-484C-AB93-C4651F43B9A7} {DF590535-2FDC-4A0A-9EE4-7C9BF818C7B4} = {F5E59EDE-6E77-484C-AB93-C4651F43B9A7} {20B75646-CBD2-4E72-8C56-22887A519FA3} = {F5E59EDE-6E77-484C-AB93-C4651F43B9A7} + {713F4937-160C-4CA3-9F9B-91DD91E7F5AC} = {F5E59EDE-6E77-484C-AB93-C4651F43B9A7} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {09914DD8-50B2-48E3-B9AB-7764AE36AD6B} diff --git a/docs/aws.md b/docs/aws.md new file mode 100644 index 0000000..f5682b6 --- /dev/null +++ b/docs/aws.md @@ -0,0 +1,23 @@ +# Connecting to AWS IoT Core + +AWS IoT Core requires X509 client certificates to connecto to the MQTT endpoint. + +These certificates can be self-signed or CA signed. + +Additionally you might need to create a Thing identity, or use a Provisioning template. + +https://docs.aws.amazon.com/iot/latest/developerguide/single-thing-provisioning.html + +https://docs.aws.amazon.com/iot/latest/developerguide/auto-register-device-cert.html + +https://aws.amazon.com/blogs/iot/just-in-time-registration-of-device-certificates-on-aws-iot/ + +As with any other MQTT broker, you can use WithConnectionSettings including the X509Key + +To support JIT, the first connection always fails and a retry is needed, in that case you can use the AwsClientFactory + + +## Shadows + +To use shadows, you must configure a "thing", associate to a ceritifcate, and enable a classic shadow. + diff --git a/samples/aws-sample/Device.cs b/samples/aws-sample/Device.cs new file mode 100644 index 0000000..fb4e7bc --- /dev/null +++ b/samples/aws-sample/Device.cs @@ -0,0 +1,53 @@ +using MQTTnet.Extensions.MultiCloud; +using MQTTnet.Extensions.MultiCloud.AwsIoTClient; +using MQTTnet.Extensions.MultiCloud.Connections; + +namespace aws_sample +{ + public class Device : BackgroundService + { + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + + public Device(ILogger logger, IConfiguration configuration) + { + _logger = logger; + _configuration = configuration; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + ConnectionSettings cs = new (_configuration.GetConnectionString("cs")); + var mqtt = await AwsClientFactory.CreateFromConnectionSettingsAsync(cs, false, stoppingToken); + Console.WriteLine(mqtt.IsConnected); + Console.WriteLine(AwsClientFactory.ComputedSettings); + var client = new AwsMqttClient(mqtt); + var shadow = await client.GetShadowAsync(stoppingToken); + Console.WriteLine(shadow); + + var res = await client.UpdateShadowAsync(new { myProp = "hello 123" }, stoppingToken); + Console.WriteLine(res); + shadow = await client.GetShadowAsync(stoppingToken); + Console.WriteLine(shadow.Contains("myProp")); + + WritableProperty wp = new(mqtt, "myWProp") + { + OnMessage = async m => + { + Console.WriteLine(m); + return await Task.FromResult(new Ack { Value = m }); + } + }; + + + await wp.InitPropertyAsync(shadow, "my default val", stoppingToken); + + + while (!stoppingToken.IsCancellationRequested) + { + _logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now); + await Task.Delay(1000, stoppingToken); + } + } + } +} \ No newline at end of file diff --git a/samples/aws-sample/Program.cs b/samples/aws-sample/Program.cs new file mode 100644 index 0000000..159fd37 --- /dev/null +++ b/samples/aws-sample/Program.cs @@ -0,0 +1,10 @@ +using aws_sample; + +IHost host = Host.CreateDefaultBuilder(args) + .ConfigureServices(services => + { + services.AddHostedService(); + }) + .Build(); + +await host.RunAsync(); diff --git a/samples/aws-sample/appsettings.Development.json b/samples/aws-sample/appsettings.Development.json new file mode 100644 index 0000000..b2dcdb6 --- /dev/null +++ b/samples/aws-sample/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/samples/aws-sample/appsettings.json b/samples/aws-sample/appsettings.json new file mode 100644 index 0000000..b2dcdb6 --- /dev/null +++ b/samples/aws-sample/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/samples/aws-sample/aws-sample.csproj b/samples/aws-sample/aws-sample.csproj new file mode 100644 index 0000000..bb7de75 --- /dev/null +++ b/samples/aws-sample/aws-sample.csproj @@ -0,0 +1,18 @@ + + + + net6.0 + enable + enable + dotnet-aws_sample-7C0BD790-EA55-4969-A22F-6B86B301A627 + aws_sample + + + + + + + + + + diff --git a/samples/memmon/Device.cs b/samples/memmon/Device.cs index a718f6e..2a6424c 100644 --- a/samples/memmon/Device.cs +++ b/samples/memmon/Device.cs @@ -54,10 +54,11 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) client.Command_malloc.OnMessage = Command_malloc_Hanlder; client.Command_free.OnMessage = Command_free_Hanlder; - await client.Property_enabled.InitPropertyAsync(client.InitialState, default_enabled, stoppingToken); + await client.Property_started.SendMessageAsync(DateTime.Now, stoppingToken); + await client.Property_interval.InitPropertyAsync(client.InitialState, default_interval, stoppingToken); + await client.Property_enabled.InitPropertyAsync(client.InitialState, default_enabled, stoppingToken); - await client.Property_started.SendMessageAsync(DateTime.Now, stoppingToken); RefreshScreen(this); diff --git a/samples/memmon/MemMonFactory.cs b/samples/memmon/MemMonFactory.cs index 17e0dcd..272d373 100644 --- a/samples/memmon/MemMonFactory.cs +++ b/samples/memmon/MemMonFactory.cs @@ -88,9 +88,11 @@ public async Task CreateMemMonClientAsync(string connectinStringName, C static async Task CreateAwsClientAsync(string connectionString, CancellationToken cancellationToken = default) { - var mqtt = await AwsClientFactory.CreateFromConnectionSettingsAsync(connectionString, cancellationToken); + var mqtt = await AwsClientFactory.CreateFromConnectionSettingsAsync(connectionString, true, cancellationToken); var client = new dtmi_rido_memmon.aws.memmon(mqtt); + connectionSettings = AwsClientFactory.ComputedSettings; nugetPackageVersion = AwsClientFactory.NuGetPackageVersion; + client.InitialState = await client.GetShadowAsync(cancellationToken); return client; } } diff --git a/samples/memmon/dtmi_rido_memmon-2.aws.g.cs b/samples/memmon/dtmi_rido_memmon-2.aws.g.cs index c6ad71a..951f093 100644 --- a/samples/memmon/dtmi_rido_memmon-2.aws.g.cs +++ b/samples/memmon/dtmi_rido_memmon-2.aws.g.cs @@ -3,8 +3,6 @@ using MQTTnet.Client; using MQTTnet.Extensions.MultiCloud; using MQTTnet.Extensions.MultiCloud.AwsIoTClient; -using MQTTnet.Extensions.MultiCloud.AwsIoTClient.TopicBindings; -using MQTTnet.Extensions.MultiCloud.BrokerIoTClient; namespace dtmi_rido_memmon.aws; @@ -17,18 +15,22 @@ public class memmon : AwsMqttClient, Imemmon public ITelemetry Telemetry_managedMemory { get; set; } public ICommand> Command_getRuntimeStats { get; set; } - public string InitialState => String.Empty; + public string InitialState { get; set; } public ICommand Command_isPrime { get; set; } public ICommand Command_malloc { get; set; } public ICommand Command_free { get; set; } - internal memmon(IMqttClient c) : base(c, Imemmon.ModelId) + internal memmon(IMqttClient c) : base(c) { Property_started = new ReadOnlyProperty(c, "started"); Property_interval = new WritableProperty(c, "interval"); - Property_enabled = new AwsWritablePropertyUTFJson(c, "enabled"); - Telemetry_workingSet = new Telemetry(c, "workingSet"); - Command_getRuntimeStats = new Command>(c, "getRuntimeStats"); + Property_enabled = new WritableProperty(c, "enabled"); + Telemetry_workingSet = new MQTTnet.Extensions.MultiCloud.BrokerIoTClient.Telemetry(c, "workingSet"); + Telemetry_managedMemory = new MQTTnet.Extensions.MultiCloud.BrokerIoTClient.Telemetry(c, "managedMemory"); + Command_getRuntimeStats = new MQTTnet.Extensions.MultiCloud.BrokerIoTClient.Command>(c, "getRuntimeStats"); + Command_isPrime = new MQTTnet.Extensions.MultiCloud.BrokerIoTClient.Command(c, "isPrime"); + Command_malloc = new MQTTnet.Extensions.MultiCloud.BrokerIoTClient.Command(c, "malloc"); + Command_free = new MQTTnet.Extensions.MultiCloud.BrokerIoTClient.Command(c, "free"); } } \ No newline at end of file diff --git a/samples/pi-sense-device/SenseHatFactory.cs b/samples/pi-sense-device/SenseHatFactory.cs index 3ca0381..79eb628 100644 --- a/samples/pi-sense-device/SenseHatFactory.cs +++ b/samples/pi-sense-device/SenseHatFactory.cs @@ -59,6 +59,7 @@ public async Task CreateSenseHatClientAsync(string connectionStringNa var cs = new ConnectionSettings(connectionString) { ModelId = Isensehat.ModelId }; var mqtt = await BrokerClientFactory.CreateFromConnectionSettingsAsync(cs, true, cancellationToken); var client = new dtmi_rido_pnp_sensehat.mqtt.sensehat(mqtt); + computedSettings = BrokerClientFactory.ComputedSettings!; nugetPackageVersion = BrokerClientFactory.NuGetPackageVersion; return client; } diff --git a/samples/pi-sense-device/RidoFY23CA.crt b/samples/pi-sense-device/ca.pem similarity index 100% rename from samples/pi-sense-device/RidoFY23CA.crt rename to samples/pi-sense-device/ca.pem diff --git a/src/MQTTnet.Extensions.MultiCloud.AwsIoTClient/AwsClientFactory.cs b/src/MQTTnet.Extensions.MultiCloud.AwsIoTClient/AwsClientFactory.cs index 1bfd63f..b2f6312 100644 --- a/src/MQTTnet.Extensions.MultiCloud.AwsIoTClient/AwsClientFactory.cs +++ b/src/MQTTnet.Extensions.MultiCloud.AwsIoTClient/AwsClientFactory.cs @@ -1,28 +1,64 @@ -using MQTTnet.Client; +using MQTTnet.Adapter; +using MQTTnet.Client; using MQTTnet.Extensions.MultiCloud.Connections; using System; using System.Threading; using System.Threading.Tasks; -namespace MQTTnet.Extensions.MultiCloud.AwsIoTClient +namespace MQTTnet.Extensions.MultiCloud.AwsIoTClient; + +public static class AwsClientFactory { - public static class AwsClientFactory - { - public static string NuGetPackageVersion => $"{ThisAssembly.AssemblyName} {ThisAssembly.NuGetPackageVersion}"; - public static ConnectionSettings? ComputedSettings { get; private set; } - public static async Task CreateFromConnectionSettingsAsync(string connectinString, CancellationToken cancellationToken = default) => - await CreateFromConnectionSettingsAsync(new ConnectionSettings(connectinString), cancellationToken); + public static string NuGetPackageVersion => $"{ThisAssembly.AssemblyName} {ThisAssembly.NuGetPackageVersion}"; + public static ConnectionSettings? ComputedSettings { get; private set; } - public static async Task CreateFromConnectionSettingsAsync(ConnectionSettings cs, CancellationToken cancellationToken = default) + public static async Task CreateFromConnectionSettingsAsync(string connectinString, bool withBirth = false, CancellationToken cancellationToken = default) => + await CreateFromConnectionSettingsAsync(new ConnectionSettings(connectinString), withBirth, cancellationToken); + + public static async Task CreateFromConnectionSettingsAsync(ConnectionSettings cs, bool withBirth = false, CancellationToken cancellationToken = default) + { + MqttClient? mqtt = new MqttFactory().CreateMqttClient(MqttNetTraceLogger.CreateTraceLogger()) as MqttClient; + try { - MqttClient? mqtt = new MqttFactory().CreateMqttClient(MqttNetTraceLogger.CreateTraceLogger()) as MqttClient; var connAck = await mqtt!.ConnectAsync(new MqttClientOptionsBuilder().WithConnectionSettings(cs).Build(), cancellationToken); if (connAck.ResultCode != MqttClientConnectResultCode.Success) { throw new ApplicationException($"Cannot connect to {cs}"); } ComputedSettings = cs; - return mqtt; } + catch (MqttConnectingFailedException ex) + { + if (ex.ResultCode == MqttClientConnectResultCode.UnspecifiedError + && ex.InnerException!.Message == "The operation has timed out.") + { + var connAck = await mqtt!.ConnectAsync(new MqttClientOptionsBuilder().WithConnectionSettings(cs).Build(), cancellationToken); + if (connAck.ResultCode != MqttClientConnectResultCode.Success) + { + throw new ApplicationException($"Cannot connect to {cs}"); + } + ComputedSettings = cs; + } + } + + if (withBirth) + { + var birthPayload = new ShadowSerializer().ToBytes( + new BirthConvention.BirthMessage(BirthConvention.ConnectionStatus.online) + { + ModelId = cs.ModelId + }); + + var pubAck = await mqtt.PublishBinaryAsync( + BirthConvention.BirthTopic(mqtt!.Options.ClientId), + birthPayload, + Protocol.MqttQualityOfServiceLevel.AtLeastOnce, true, cancellationToken); + if (pubAck.ReasonCode != MqttClientPublishReasonCode.Success) + { + throw new ApplicationException($"Error publishing Birth {cs}"); + } + } + + return mqtt!; } } diff --git a/src/MQTTnet.Extensions.MultiCloud.AwsIoTClient/AwsMqttClient.cs b/src/MQTTnet.Extensions.MultiCloud.AwsIoTClient/AwsMqttClient.cs index 8a88fa0..80c4315 100644 --- a/src/MQTTnet.Extensions.MultiCloud.AwsIoTClient/AwsMqttClient.cs +++ b/src/MQTTnet.Extensions.MultiCloud.AwsIoTClient/AwsMqttClient.cs @@ -1,38 +1,25 @@ using MQTTnet.Client; -using MQTTnet.Extensions.MultiCloud.AwsIoTClient.TopicBindings; using MQTTnet.Extensions.MultiCloud.Connections; using MQTTnet.Extensions.MultiCloud.Serializers; using System.Threading; using System.Threading.Tasks; -namespace MQTTnet.Extensions.MultiCloud.AwsIoTClient -{ - public class AwsMqttClient - { - public IMqttClient Connection { get; private set; } - private readonly ShadowRequestResponseBinder getShadowBinder; - +namespace MQTTnet.Extensions.MultiCloud.AwsIoTClient; - public AwsMqttClient(IMqttClient c, string modelId = "") //: base(c) - { - Connection = c; - var birthMsg = - new UTF8JsonSerializer().ToBytes( - new BirthConvention.BirthMessage(BirthConvention.ConnectionStatus.online) - { - ModelId = modelId - }); - _ = Connection.PublishBinaryAsync( - BirthConvention.BirthTopic(Connection.Options.ClientId), - birthMsg, - Protocol.MqttQualityOfServiceLevel.AtLeastOnce, true); +public class AwsMqttClient +{ + public IMqttClient Connection { get; private set; } + private readonly ShadowRequestResponseBinder getShadowBinder; - getShadowBinder = new ShadowRequestResponseBinder(c); - } - public Task GetShadowAsync(CancellationToken cancellationToken = default) => - getShadowBinder.GetShadowAsync(cancellationToken); - public Task UpdateShadowAsync(object payload, CancellationToken cancellationToken = default) => - getShadowBinder.UpdateShadowAsync(payload, cancellationToken); + public AwsMqttClient(IMqttClient c) //: base(c) + { + Connection = c; + getShadowBinder = new ShadowRequestResponseBinder(c); } + + public Task GetShadowAsync(CancellationToken cancellationToken = default) => + getShadowBinder.GetShadowAsync(cancellationToken); + public Task UpdateShadowAsync(object payload, CancellationToken cancellationToken = default) => + getShadowBinder.UpdateShadowAsync(payload, cancellationToken); } diff --git a/src/MQTTnet.Extensions.MultiCloud.AwsIoTClient/ReadOnlyProperty.cs b/src/MQTTnet.Extensions.MultiCloud.AwsIoTClient/ReadOnlyProperty.cs new file mode 100644 index 0000000..ab06bad --- /dev/null +++ b/src/MQTTnet.Extensions.MultiCloud.AwsIoTClient/ReadOnlyProperty.cs @@ -0,0 +1,23 @@ +using MQTTnet.Client; +using System.Threading.Tasks; +using System.Threading; + +namespace MQTTnet.Extensions.MultiCloud.AwsIoTClient; + +public class ReadOnlyProperty : IReadOnlyProperty +{ + readonly IMqttClient _connection; + readonly string _name; + public ReadOnlyProperty(IMqttClient mqttClient, string name) + { + _connection = mqttClient; + _name = name; + } + public async Task SendMessageAsync(T payload, CancellationToken cancellationToken = default) + { + ShadowSerializer serializer = new(); + var topic = $"$aws/things/{_connection.Options.ClientId}/shadow/update"; + var payloadBytes = serializer.ToBytes(payload, _name); + await _connection.PublishBinaryAsync(topic, payloadBytes, Protocol.MqttQualityOfServiceLevel.AtLeastOnce, false, cancellationToken); + } +} diff --git a/src/MQTTnet.Extensions.MultiCloud.AwsIoTClient/TopicBindings/ShadowRequestResponseBinder.cs b/src/MQTTnet.Extensions.MultiCloud.AwsIoTClient/ShadowRequestResponseBinder.cs similarity index 69% rename from src/MQTTnet.Extensions.MultiCloud.AwsIoTClient/TopicBindings/ShadowRequestResponseBinder.cs rename to src/MQTTnet.Extensions.MultiCloud.AwsIoTClient/ShadowRequestResponseBinder.cs index aeb5173..569b32b 100644 --- a/src/MQTTnet.Extensions.MultiCloud.AwsIoTClient/TopicBindings/ShadowRequestResponseBinder.cs +++ b/src/MQTTnet.Extensions.MultiCloud.AwsIoTClient/ShadowRequestResponseBinder.cs @@ -2,23 +2,26 @@ using MQTTnet.Extensions.MultiCloud.Serializers; using MQTTnet.Protocol; using System; +using System.Collections; using System.Collections.Concurrent; +using System.Collections.Generic; using System.Diagnostics; using System.Text; using System.Threading; using System.Threading.Tasks; using System.Web; -namespace MQTTnet.Extensions.MultiCloud.AwsIoTClient.TopicBindings; +namespace MQTTnet.Extensions.MultiCloud.AwsIoTClient; public class ShadowRequestResponseBinder { internal int lastRid = -1; private readonly ConcurrentDictionary> pendingGetshadowRequests = new(); + private readonly ConcurrentDictionary> pendingUpdateShadowRequests = new(); public Func>? OnMessage { get; set; } private readonly IMqttClient connection; - private readonly string topicBase = String.Empty; + private readonly string topicBase = string.Empty; public ShadowRequestResponseBinder(IMqttClient connection) { this.connection = connection; @@ -27,23 +30,35 @@ public ShadowRequestResponseBinder(IMqttClient connection) connection.ApplicationMessageReceivedAsync += async m => { - await Task.Yield(); - var topic = m.ApplicationMessage.Topic; - if (topic.StartsWith(topicBase + "/+/accepted")) + string msg = Encoding.UTF8.GetString(m.ApplicationMessage.Payload ?? Array.Empty()); + if (topic.StartsWith(topicBase + "/get/accepted")) + { + //(int rid, _) = TopicParser.ParseTopic(topic); + if (pendingGetshadowRequests.TryGetValue(RidCounter.Current, out var tcs)) + { + tcs.SetResult(msg); + Trace.TraceWarning($"GetshadowBinder: RID {RidCounter.Current} found in pending requests"); + } + else + { + Trace.TraceWarning($"GetshadowBinder: RID {RidCounter.Current} not found pending requests"); + } + } + if (topic.StartsWith(topicBase + "/update/accepted")) { - string msg = Encoding.UTF8.GetString(m.ApplicationMessage.Payload ?? Array.Empty()); - (int rid, _) = TopicParser.ParseTopic(topic); - if (pendingGetshadowRequests.TryGetValue(rid, out var tcs)) + if (pendingUpdateShadowRequests.TryGetValue(RidCounter.Current, out var tcs)) { tcs.SetResult(msg); - Trace.TraceWarning($"GetshadowBinder: RID {rid} found in pending requests"); + Trace.TraceWarning($"UpdateshadowBinder: RID {RidCounter.Current} found in pending requests"); } else { - Trace.TraceWarning($"GetshadowBinder: RID {rid} not found pending requests"); + Trace.TraceWarning($"UpdateshadowBinder: RID {RidCounter.Current} not found pending requests"); } + } + await Task.Yield(); }; } @@ -80,12 +95,20 @@ public async Task GetShadowAsync(CancellationToken cancellationToken = d public async Task UpdateShadowAsync(object payload, CancellationToken cancellationToken = default) { - await connection.SubscribeWithReplyAsync("$iothub/shadow/res/#", cancellationToken: cancellationToken); + await connection.SubscribeWithReplyAsync(topicBase + "/update/+", cancellationToken: cancellationToken); var rid = RidCounter.NextValue(); + var shadowUpdate = new + { + state = new + { + reported = payload + } + }; + var puback = await connection.PublishBinaryAsync( - topicBase + "/shadow/update", - new UTF8JsonSerializer().ToBytes(payload), + topicBase + "/update", + new ShadowSerializer().ToBytes(shadowUpdate), MqttQualityOfServiceLevel.AtMostOnce, false, cancellationToken); @@ -93,7 +116,7 @@ public async Task UpdateShadowAsync(object payload, CancellationToken ca var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); if (puback.ReasonCode == 0) { - if (pendingGetshadowRequests.TryAdd(rid, tcs)) + if (pendingUpdateShadowRequests.TryAdd(rid, tcs)) { Trace.TraceWarning($"UpdshadowBinder: RID {rid} added to pending requests"); } diff --git a/src/MQTTnet.Extensions.MultiCloud.AwsIoTClient/ShadowSerializer.cs b/src/MQTTnet.Extensions.MultiCloud.AwsIoTClient/ShadowSerializer.cs new file mode 100644 index 0000000..6e2231b --- /dev/null +++ b/src/MQTTnet.Extensions.MultiCloud.AwsIoTClient/ShadowSerializer.cs @@ -0,0 +1,74 @@ +using System.Collections.Generic; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MQTTnet.Extensions.MultiCloud.AwsIoTClient; + +public class ShadowSerializer //: IMessageSerializer +{ + public int Version { get; set; } + private static class Json + { + public static string Stringify(object o) => JsonSerializer.Serialize(o, + new JsonSerializerOptions() + { + Converters = + { + new JsonStringEnumConverter() + } + }); + public static T FromString(string s) => JsonSerializer.Deserialize(s, + new JsonSerializerOptions() + { + Converters = + { + new JsonStringEnumConverter() + } + })!; + } + + public byte[] ToBytes(T payload, string name = "", int? version = null) + { + if (string.IsNullOrEmpty(name)) + { + return Encoding.UTF8.GetBytes(Json.Stringify(payload!)); + } + else + { + var patch = new + { + state = new + { + reported = new Dictionary + { + {name, payload } + } + } + }; + return Encoding.UTF8.GetBytes(Json.Stringify(patch)); + } + } + + public bool TryReadFromBytes(byte[] payload, string name, out T result) + { + bool found = false; + string payloadString = Encoding.UTF8.GetString(payload); + JsonDocument payloadJson = JsonDocument.Parse(payloadString); + if (payloadJson.RootElement.GetProperty("version").TryGetInt32(out int v)) + { + Version = v; + } + var state = payloadJson.RootElement.GetProperty("state"); + if (state.TryGetProperty(name, out JsonElement propValue)) + { + found = true; + result = propValue.Deserialize()!; + } + else + { + result = default!; + } + return found; + } +} diff --git a/src/MQTTnet.Extensions.MultiCloud.AwsIoTClient/TopicBindings/AwsWritablePropertyUTFJson.cs b/src/MQTTnet.Extensions.MultiCloud.AwsIoTClient/TopicBindings/AwsWritablePropertyUTFJson.cs deleted file mode 100644 index 977d3a9..0000000 --- a/src/MQTTnet.Extensions.MultiCloud.AwsIoTClient/TopicBindings/AwsWritablePropertyUTFJson.cs +++ /dev/null @@ -1,37 +0,0 @@ -using MQTTnet.Client; -using MQTTnet.Extensions.MultiCloud.Binders; -using MQTTnet.Extensions.MultiCloud.Serializers; -using System.Threading; -using System.Threading.Tasks; - -namespace MQTTnet.Extensions.MultiCloud.AwsIoTClient.TopicBindings -{ - public class AwsWritablePropertyUTFJson : CloudToDeviceBinder>, IWritableProperty - { - public T? Value { get; set; } - public int? Version { get; set; } - - public AwsWritablePropertyUTFJson(IMqttClient c, string name) - : base(c, name, new UTF8JsonSerializer()) - { - RequestTopicPattern = "$aws/things/{deviceId}/shadow/#"; - ResponseTopicPattern = "$aws/things/{deviceId}/shadow/accepted"; - UnwrapRequest = true; - WrapResponse = true; - PreProcessMessage = tp => - { - Version = tp.Version; - }; - } - - public Task SendMessageAsync(Ack payload, CancellationToken cancellationToken = default) - { - throw new System.NotImplementedException(); - } - - public Task InitPropertyAsync(string intialState, T defaultValue, CancellationToken cancellationToken = default) - { - throw new System.NotImplementedException(); - } - } -} diff --git a/src/MQTTnet.Extensions.MultiCloud.AwsIoTClient/WritableProperty.cs b/src/MQTTnet.Extensions.MultiCloud.AwsIoTClient/WritableProperty.cs new file mode 100644 index 0000000..40ce62e --- /dev/null +++ b/src/MQTTnet.Extensions.MultiCloud.AwsIoTClient/WritableProperty.cs @@ -0,0 +1,93 @@ +using MQTTnet.Client; +using MQTTnet.Protocol; +using System; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace MQTTnet.Extensions.MultiCloud.AwsIoTClient; + +public class WritableProperty : IWritableProperty, IDeviceToCloud> +{ + public T? Value { get; set; } + public int? Version { get; set; } + + public Func>>? OnMessage { get; set; } + + readonly ShadowSerializer serializer = new(); + const string subscribeTopicPattern = "$aws/things/{clientId}/shadow/update/delta"; + const string responseTopic = "$aws/things/{clientId}/shadow/update"; + + readonly IMqttClient _connection; + readonly string _name; + + public WritableProperty(IMqttClient c, string name) + { + _connection = c; + _name = name; + var deltaTopic = subscribeTopicPattern.Replace("{clientId}", c.Options.ClientId); + _ = c.SubscribeAsync(deltaTopic); + c.ApplicationMessageReceivedAsync += async msg => + { + var topic = msg.ApplicationMessage.Topic; + if (topic == deltaTopic) + { + if (serializer.TryReadFromBytes(msg.ApplicationMessage.Payload, name, out T req)) + { + if (serializer.Version > Version) + { + Version = serializer.Version + 1; + Ack resp = await OnMessage?.Invoke(req)!; + if (resp != null) + { + var resTopic = responseTopic.Replace("{clientId}", c.Options.ClientId); + byte[] responseBytes = serializer.ToBytes(resp, name, serializer.Version); + _ = c.PublishAsync( + new MqttApplicationMessageBuilder() + .WithTopic(resTopic) + .WithPayload(responseBytes) + .WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce) + .Build()); + } + } + } + } + }; + } + + public async Task SendMessageAsync(Ack payload, CancellationToken cancellationToken = default) + { + var resTopic = responseTopic.Replace("{clientId}", _connection.Options.ClientId); + await _connection.PublishAsync(new MqttApplicationMessageBuilder() + .WithTopic(resTopic) + .WithPayload(serializer.ToBytes(payload, _name, payload.Version!.Value)) + .WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce) + .Build(), cancellationToken); + } + + public async Task InitPropertyAsync(string initialState, T defaultValue, CancellationToken cancellationToken = default) + { + Ack reported = new(); + + JsonDocument shadowDoc = JsonDocument.Parse(initialState); + JsonElement versionEl = shadowDoc.RootElement.GetProperty("version"); + if (versionEl.TryGetInt32(out int version)) + { + Version = version; + } + + reported.Version = Version; + + JsonElement desiredNode = shadowDoc.RootElement.GetProperty("state")!.GetProperty("desired")!; + if (desiredNode.TryGetProperty(_name, out JsonElement desiredVal)) + { + Value = desiredVal.Deserialize()!; + } + else + { + Value = defaultValue; + } + reported.Value = Value; + await SendMessageAsync(reported, cancellationToken); + } +} diff --git a/src/MQTTnet.Extensions.MultiCloud.AzureIoTClient/WritableProperty.cs b/src/MQTTnet.Extensions.MultiCloud.AzureIoTClient/WritableProperty.cs index 7a1abb0..c345b1e 100644 --- a/src/MQTTnet.Extensions.MultiCloud.AzureIoTClient/WritableProperty.cs +++ b/src/MQTTnet.Extensions.MultiCloud.AzureIoTClient/WritableProperty.cs @@ -3,8 +3,8 @@ namespace MQTTnet.Extensions.MultiCloud.AzureIoTClient; -public class WritableProperty : CloudToDeviceBinder>, IWritableProperty -{ +public class WritableProperty : CloudToDeviceBinder>, IWritableProperty, IDeviceToCloud> +{ readonly IMqttClient _connection; readonly string _name; public T? Value { get; set; } diff --git a/src/MQTTnet.Extensions.MultiCloud.BrokerIoTClient/BrokerClientFactory.cs b/src/MQTTnet.Extensions.MultiCloud.BrokerIoTClient/BrokerClientFactory.cs index 7927083..93c2730 100644 --- a/src/MQTTnet.Extensions.MultiCloud.BrokerIoTClient/BrokerClientFactory.cs +++ b/src/MQTTnet.Extensions.MultiCloud.BrokerIoTClient/BrokerClientFactory.cs @@ -17,7 +17,6 @@ public static async Task CreateFromConnectionSettingsAsync(Connecti MqttClient? mqtt = new MqttFactory().CreateMqttClient(MqttNetTraceLogger.CreateTraceLogger()) as MqttClient; var connAck = await mqtt!.ConnectAsync(new MqttClientOptionsBuilder() .WithConnectionSettings(cs, withBirth) - .WithProtocolVersion(Formatter.MqttProtocolVersion.V311) .Build(), cancellationToken); ComputedSettings = cs; if (connAck.ResultCode != MqttClientConnectResultCode.Success) diff --git a/src/MQTTnet.Extensions.MultiCloud.BrokerIoTClient/WritableProperty.cs b/src/MQTTnet.Extensions.MultiCloud.BrokerIoTClient/WritableProperty.cs index 271bbcd..a5e072a 100644 --- a/src/MQTTnet.Extensions.MultiCloud.BrokerIoTClient/WritableProperty.cs +++ b/src/MQTTnet.Extensions.MultiCloud.BrokerIoTClient/WritableProperty.cs @@ -3,7 +3,7 @@ namespace MQTTnet.Extensions.MultiCloud.BrokerIoTClient; -public class WritableProperty : CloudToDeviceBinder>, IWritableProperty +public class WritableProperty : CloudToDeviceBinder>, IWritableProperty, IDeviceToCloud> { readonly IMqttClient _connection; readonly string _name; diff --git a/src/MQTTnet.Extensions.MultiCloud/IWritableProperty.cs b/src/MQTTnet.Extensions.MultiCloud/IWritableProperty.cs index 2e9c1b4..34099d5 100644 --- a/src/MQTTnet.Extensions.MultiCloud/IWritableProperty.cs +++ b/src/MQTTnet.Extensions.MultiCloud/IWritableProperty.cs @@ -11,5 +11,5 @@ public interface IWritableProperty : ICloudToDevice>, IDeviceToClou { T? Value { get; set; } int? Version { get; set; } - Task InitPropertyAsync(string intialState, T defaultValue, CancellationToken cancellationToken = default); + Task InitPropertyAsync(string initialState, T defaultValue, CancellationToken cancellationToken = default); } diff --git a/tests/MQTTnet.Extensions.MultiCloud.IntegrationTests/AwsConnectionFixture.cs b/tests/MQTTnet.Extensions.MultiCloud.IntegrationTests/AwsConnectionFixture.cs index 7d62314..151e025 100644 --- a/tests/MQTTnet.Extensions.MultiCloud.IntegrationTests/AwsConnectionFixture.cs +++ b/tests/MQTTnet.Extensions.MultiCloud.IntegrationTests/AwsConnectionFixture.cs @@ -22,8 +22,7 @@ public async Task ClientCert() var cs = new ConnectionSettings() { HostName = "a38jrw6jte2l2x-ats.iot.us-west-1.amazonaws.com", - ClientId = "testdevice22", - X509Key = "testdevice22.pem|testdevice22.key" + X509Key = "ca-device.pem|ca-device.key" }; var connAck = await client.ConnectAsync(new MqttClientOptionsBuilder() .WithConnectionSettings(cs) diff --git a/tests/MQTTnet.Extensions.MultiCloud.IntegrationTests/DpsConnectionFixture.cs b/tests/MQTTnet.Extensions.MultiCloud.IntegrationTests/DpsConnectionFixture.cs index 86453e0..541ada9 100644 --- a/tests/MQTTnet.Extensions.MultiCloud.IntegrationTests/DpsConnectionFixture.cs +++ b/tests/MQTTnet.Extensions.MultiCloud.IntegrationTests/DpsConnectionFixture.cs @@ -44,7 +44,7 @@ public async Task ClientCert() var cs = new ConnectionSettings() { IdScope = "0ne001F8884", - X509Key = "testdevice22.pem|testdevice22.key" + X509Key = "ca-device.pem|ca-device.key" }; var connAck = await client.ConnectAsync(new MqttClientOptionsBuilder() .WithAzureDpsCredentials(cs) diff --git a/tests/MQTTnet.Extensions.MultiCloud.IntegrationTests/testdevice22.key b/tests/MQTTnet.Extensions.MultiCloud.IntegrationTests/testdevice22.key deleted file mode 100644 index b1b0577..0000000 --- a/tests/MQTTnet.Extensions.MultiCloud.IntegrationTests/testdevice22.key +++ /dev/null @@ -1,27 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -MIIEpAIBAAKCAQEA26QU3lxho3ArONd/g5hMD3O8BPkxew7gj5do6Nes522uNU2W -+miQCWPUiJDJv5X0AS0XqLzTjZHj+372wfqH3Zq0ghg8cxqrZUfw2fnd1Yc+9jHK -iUysVwXXOAucVkn6QElrJUNMwJpseEOlAP3WXXZ8ANzBmeCy/cVF2yUee5z8O58C -cejfsxe03rvR6JgZ46YHca4+PWW4VYw/QPfxs4/6nrxUwOk+TlMC8nbGj38WFwDs -giwxU7Houp8QciBhjK816QF7fM2QutKluhOLZEVOonAT9ql0l7ycKsQDPCM2bowC -kNw94JI/qUC1v8uCCj3sr37KF0vXdsy0cOI94QIDAQABAoIBAG5jdwGS2Je2BmMZ -G8Ndxcx2VGcDfzTj80T3yghMbWAbJdUwW9MavlmZzPWeH2eky+/TY/KwhHrAeh6q -XwZ7DdH4sQQ5fhgzrTTIBWroQ+/Pe3ts5ug+mtdZ3iMS4s4TfCzXOXyO6IJcmmuP -Q9zNtvFeXVBpovvdjNgyT7umWj2xaC06e/+1X1htIghL1ytReLDKe80PnXQ0T2w4 -hRWPMqqcrUeI9My+xqVSRjtaiV1jeBQtIMA/2hMCW2GnI1gHxrQeqDJr6NpGd61j -KIf/PoUtplYlxxZM2J1z1hzrCtPg8tRkWYZ6t4XOd9830ATJdOokIGGSs77MR4wk -/PBbO0kCgYEA9WR+FiucSF/5IW1qetrzBmuPl0y9oQ/HnFk8rB2gmwcovCKfso88 -db7yuEI9gPO/KQNCj5cUBFH74b80Z3EFjdscnfkhZeY/RzUptDCG9Ri3tClz3WVH -9OFD0186Cwlldxf3jB6KUf4OzHySwVlPCpeDLN/71Nx9/21dMZOYJlMCgYEA5SKf -Ve2XqYNdiuXO4WOZoqVn5sROH/RKt3y5JFJcU5jegFBCZ1xo3/gXYGwaNW1j1HUm -lKZSTyakHfzXuif88emlZlObaj3BlX0Oso7h8+Jo6C4vVDXbMENkuE/r1rlbaIcx -if6y/vQkDoL/8QnArXPPO4/Bd5KxEZAveb62XHsCgYEAg5/opdm0lXmtNwnqkIV6 -z9nIBp7qJEdDiZCwI54stJstdWSZBn0gMARBSyZ0uQOFaOlPvZeK7BpRojAeWtHb -6dA61F16VZ8gGADjIrzY1wTwHJoThRC7fRWLfQhaKOtcWnonlSgYh4xfwb2cNkvC -QussOFjYSsAOcYDm+R2zAfMCgYBKYE+qG8JEAuElSe06WhMvqVMtpADb/HcYUkLi -mF1hwVhP/D1hwaxFLwmfgdeMCiMCfrh0PfyvtpJFLaJ0ybAgcj//FBp5hOxGct6H -dTvA53g4DW3NfuHUK7wQ29hozafvKRsaePNvMJ5Xm4wqmIjThKj643HgN1yEn/HZ -Kd+V6wKBgQDpKb4le1vrofwWM+rJPKwkx7EfjyeqQgFoneLJsjdzF7/wL/GswN2Z -OMJv8wCRkROm6dpJk6O+ni9j/Bej5k2sSIIzBoLro7y+AZRoVWDOeDWNDThyHxtQ -Gm8ETzxt/9qWsuKgNuJ3atSvEVRLJjUisBCsK7sozzdp2ENsTT6sWQ== ------END RSA PRIVATE KEY----- diff --git a/tests/MQTTnet.Extensions.MultiCloud.IntegrationTests/testdevice22.pem b/tests/MQTTnet.Extensions.MultiCloud.IntegrationTests/testdevice22.pem deleted file mode 100644 index 731b6db..0000000 --- a/tests/MQTTnet.Extensions.MultiCloud.IntegrationTests/testdevice22.pem +++ /dev/null @@ -1,49 +0,0 @@ -Bag Attributes - 1.3.6.1.4.1.311.17.3.121: 00 - localKeyID: 01 00 00 00 - 1.3.6.1.4.1.311.17.3.71: 52 00 69 00 64 00 6F 00 42 00 6C 00 61 00 63 00 6B 00 53 00 00 00 -subject=CN = testdevice22 - -issuer=CN = ridocafy22 - ------BEGIN CERTIFICATE----- -MIIDJzCCAg+gAwIBAgIQK43teVszFaFMJjturddpeDANBgkqhkiG9w0BAQsFADAV -MRMwEQYDVQQDDApyaWRvY2FmeTIyMB4XDTIxMTIxNTIwMTcyN1oXDTMwMDQxNTIw -MjcyN1owFzEVMBMGA1UEAwwMdGVzdGRldmljZTIyMIIBIjANBgkqhkiG9w0BAQEF -AAOCAQ8AMIIBCgKCAQEA26QU3lxho3ArONd/g5hMD3O8BPkxew7gj5do6Nes522u -NU2W+miQCWPUiJDJv5X0AS0XqLzTjZHj+372wfqH3Zq0ghg8cxqrZUfw2fnd1Yc+ -9jHKiUysVwXXOAucVkn6QElrJUNMwJpseEOlAP3WXXZ8ANzBmeCy/cVF2yUee5z8 -O58Ccejfsxe03rvR6JgZ46YHca4+PWW4VYw/QPfxs4/6nrxUwOk+TlMC8nbGj38W -FwDsgiwxU7Houp8QciBhjK816QF7fM2QutKluhOLZEVOonAT9ql0l7ycKsQDPCM2 -bowCkNw94JI/qUC1v8uCCj3sr37KF0vXdsy0cOI94QIDAQABo3EwbzAOBgNVHQ8B -Af8EBAMCBDAwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMBMB8GA1UdIwQY -MBaAFATO8dLgNCAeVCyVXHTPZ/tKe22JMB0GA1UdDgQWBBTX7T09qtLiZN0zW+6w -UZKsdR2DIjANBgkqhkiG9w0BAQsFAAOCAQEAQEVL34luYbdmGKq0DgdSAOB74Fkr -BXWOgpAoN+7jI5VIbKATaBrTVWqb65N5qO5TBDwOjxMvx/qiY5bn1cRJl6ACDfed -irNs2aFj6pbON8/ojIOw2JZPz44sJ8dMNuDoOyVjEe1CM+IWqlY3KpsESehFUCBI -Vsh04MWEJyU0iuKBcmH77Iz7u48MPQeN94roOZHZ+0v65Haalc1T3m6zgN5asTsV -B7Jg2fPR67VxiwCVMdzvLSvruJxK4ibWPErlBED6YNjeRtQcG39boppIV26hLHqZ -7iF2YuIO0jnvvQ2AZMOTruWzIHLXCwV0mhAW1ngBnWh2LBlZzEYoSLm6LQ== ------END CERTIFICATE----- -Bag Attributes: -subject=CN = ridocafy22 - -issuer=CN = ridocafy22 - ------BEGIN CERTIFICATE----- -MIICyzCCAbOgAwIBAgIUPh/CfFW2v2IAAHPPRoOn2DTd86UwDQYJKoZIhvcNAQEL -BQAwFTETMBEGA1UEAwwKcmlkb2NhZnkyMjAeFw0yMTA5MjEwNDE0NDdaFw0yMzAy -MDMwNDE0NDdaMBUxEzARBgNVBAMMCnJpZG9jYWZ5MjIwggEiMA0GCSqGSIb3DQEB -AQUAA4IBDwAwggEKAoIBAQCrXflb9C84jYfU6VEK0gwv3ejvkv7dFkxhu1SxU71x -nabSen8zhswq1o5L2ogR97eInZXV76AXqvJ51gaF/yCIktFsIIALKWL2Lmihu79P -UAY2+5lbFji+W95yPhta8EPywjm7OIUQvj9cuHxy1FLvY1bDE07axRkALgmptZfX -EH8VSWwBQaJKw7Tlo5+27CyvraY2+Q/kOU9ibBfGhEaeK/VG9RkwXQlUmxg0IOQ3 -uJj7E5K1T7nJBr/f1LR+fAqt/6g5Bv096mrYG8LpFwK6sDQPz8/mYzPe98zpK1KP -6VEYOxf+s6n3VJCVY/86UyaJvWawe59yRYmNXH8e99HJAgMBAAGjEzARMA8GA1Ud -EwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAG5myyl2nqidAukb5NU7m+HO -B1fq2h1TMhebzufLzZQwWdH0IQN5zLF0VSxmY03Y4yFczohx1/QRrgWxQ8gFOKUr -YYKBk/B0Va8cKMihP9KaT6k8koTHIDKyeIO7Q87sWxHCMG/TrqVKlJ1ST4X50O3M -pEdrlzG+SXRxH8FDjSpkm9FYy1/YaSKOF55GxYvUE66nO1fZipul3SDrrOkHBuPu -3hXyz6WYMmjZ10pJ+PdzZjqL3zzHTxDpJV0nzKtknMuatkljx61kfqmCS1bpWQWs -QKniBWGrUmBUUkAUWN7xkL+kSQ2PBlaGJNj/e0Pj2INyvckLwFokyD3q+MeRuHM= ------END CERTIFICATE----- diff --git a/version.json b/version.json index 017e6a5..449d0a1 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "0.4", + "version": "0.5", "publicReleaseRefSpec": [ "^refs/heads/master$", "^refs/heads/rel/v\\d+(?:\\.\\d+)?$"