diff --git a/Meadow.CLI.Core/CloudServices/CloudServiceBase.cs b/Meadow.CLI.Core/CloudServices/CloudServiceBase.cs index 5a1b125e..195ebc45 100644 --- a/Meadow.CLI.Core/CloudServices/CloudServiceBase.cs +++ b/Meadow.CLI.Core/CloudServices/CloudServiceBase.cs @@ -1,32 +1,30 @@ using Meadow.CLI.Core.Exceptions; using Meadow.CLI.Core.Identity; -using System; -using System.Collections.Generic; -using System.Net.Http.Headers; using System.Net.Http; -using System.Text; +using System.Net.Http.Headers; +using System.Threading; using System.Threading.Tasks; namespace Meadow.CLI.Core.CloudServices { public abstract class CloudServiceBase { - IdentityManager _identityManager; + readonly IdentityManager _identityManager; protected CloudServiceBase(IdentityManager identityManager) { _identityManager = identityManager; } - protected async Task AuthenticatedHttpClient() + protected async Task GetAuthenticatedHttpClient(CancellationToken cancellationToken = default) { - var authToken = await _identityManager.GetAccessToken(); + var authToken = await _identityManager.GetAccessToken(cancellationToken); if (string.IsNullOrEmpty(authToken)) { throw new MeadowCloudAuthException(); } - HttpClient client = new HttpClient(); + HttpClient client = new(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", authToken); return client; diff --git a/Meadow.CLI.Core/CloudServices/CollectionService.cs b/Meadow.CLI.Core/CloudServices/CollectionService.cs index 5d33d703..b9e42de1 100644 --- a/Meadow.CLI.Core/CloudServices/CollectionService.cs +++ b/Meadow.CLI.Core/CloudServices/CollectionService.cs @@ -1,28 +1,20 @@ -using System; +using Meadow.CLI.Core.CloudServices.Messages; +using Meadow.CLI.Core.Identity; +using Microsoft.Extensions.Configuration; using System.Collections.Generic; -using System.IO; -using System.Net.Http; -using System.Net.Http.Headers; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Meadow.CLI.Core.CloudServices.Messages; -using Meadow.CLI.Core.DeviceManagement.Tools; -using Meadow.CLI.Core.Exceptions; -using Meadow.CLI.Core.Identity; -using Microsoft.Extensions.Configuration; namespace Meadow.CLI.Core.CloudServices; public class CollectionService : CloudServiceBase { - IConfiguration _config; - IdentityManager _identityManager; + readonly IConfiguration _config; public CollectionService(IConfiguration config, IdentityManager identityManager) : base(identityManager) { _config = config; - _identityManager = identityManager; } public async Task> GetOrgCollections(string orgId, string host, CancellationToken cancellationToken) @@ -32,16 +24,9 @@ public async Task> GetOrgCollections(string orgId, string host, host = _config[Constants.MEADOW_CLOUD_HOST_CONFIG_NAME]; } - var authToken = await _identityManager.GetAccessToken(cancellationToken).ConfigureAwait(false); - if (string.IsNullOrEmpty(authToken)) - { - throw new MeadowCloudAuthException(); - } - - HttpClient httpClient = new HttpClient(); - httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", authToken); + var httpClient = await GetAuthenticatedHttpClient(cancellationToken); var result = await httpClient.GetStringAsync($"{host}/api/orgs/{orgId}/collections"); - return JsonSerializer.Deserialize>(result); + return JsonSerializer.Deserialize>(result) ?? new List(); } } \ No newline at end of file diff --git a/Meadow.CLI.Core/CloudServices/CommandService.cs b/Meadow.CLI.Core/CloudServices/CommandService.cs new file mode 100644 index 00000000..e6ae5f4d --- /dev/null +++ b/Meadow.CLI.Core/CloudServices/CommandService.cs @@ -0,0 +1,54 @@ +using Meadow.CLI.Core.Exceptions; +using Meadow.CLI.Core.Identity; +using Microsoft.Extensions.Configuration; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Meadow.CLI.Core.CloudServices +{ + public class CommandService : CloudServiceBase + { + readonly IConfiguration _config; + readonly IdentityManager _identityManager; + + public CommandService(IConfiguration config, IdentityManager identityManager) : base(identityManager) + { + _config = config; + _identityManager = identityManager; + } + + public async Task PublishCommandForCollection( + string collectionId, + string commandName, + JsonDocument? arguments = null, + int qualityOfService = 0, + string? host = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(host)) + { + host = _config[Constants.MEADOW_CLOUD_HOST_CONFIG_NAME]; + } + + var httpClient = await GetAuthenticatedHttpClient(cancellationToken); + + var payload = new + { + commandName, + args = arguments, + qos = qualityOfService + }; + var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); + var response = await httpClient.PostAsync($"{host}/api/collections/{collectionId}/commands", content, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + var message = await response.Content.ReadAsStringAsync(); + throw new MeadowCloudException(message); + } + } + } +} diff --git a/Meadow.CLI.Core/CloudServices/DeviceService.cs b/Meadow.CLI.Core/CloudServices/DeviceService.cs index bc9013bd..7e758ecf 100644 --- a/Meadow.CLI.Core/CloudServices/DeviceService.cs +++ b/Meadow.CLI.Core/CloudServices/DeviceService.cs @@ -1,32 +1,30 @@ -using System; +using Meadow.CLI.Core.Identity; +using Microsoft.Extensions.Configuration; using System.Net.Http; -using System.Net.Http.Headers; using System.Text; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; -using Meadow.CLI.Core.Exceptions; -using Meadow.CLI.Core.Identity; -using Microsoft.Extensions.Configuration; namespace Meadow.CLI.Core.CloudServices { public class DeviceService : CloudServiceBase { - IConfiguration _config; + readonly IConfiguration _config; public DeviceService(IConfiguration config, IdentityManager identityManager) : base(identityManager) { _config = config; } - public async Task<(bool isSuccess, string message)> AddDevice(string orgId, string id, string publicKey, string collectionId, string host) + public async Task<(bool isSuccess, string message)> AddDevice(string orgId, string id, string publicKey, string collectionId, string host, CancellationToken cancellationToken = default) { if (string.IsNullOrEmpty(host)) { host = _config[Constants.MEADOW_CLOUD_HOST_CONFIG_NAME]; } - var httpClient = await AuthenticatedHttpClient(); + var httpClient = await GetAuthenticatedHttpClient(cancellationToken); dynamic payload = new { @@ -39,7 +37,7 @@ public DeviceService(IConfiguration config, IdentityManager identityManager) : b var json = JsonSerializer.Serialize(payload); var content = new StringContent(json, Encoding.UTF8, "application/json"); - var response = await httpClient.PostAsync($"{host}/api/devices", content); + var response = await httpClient.PostAsync($"{host}/api/devices", content, cancellationToken); if (response.IsSuccessStatusCode) { diff --git a/Meadow.CLI.Core/CloudServices/PackageService.cs b/Meadow.CLI.Core/CloudServices/PackageService.cs index bf54124f..853f682b 100644 --- a/Meadow.CLI.Core/CloudServices/PackageService.cs +++ b/Meadow.CLI.Core/CloudServices/PackageService.cs @@ -1,29 +1,27 @@ -using Meadow.CLI.Core.Identity; -using System.Text.Json; +using Meadow.CLI.Core.CloudServices.Messages; +using Meadow.CLI.Core.DeviceManagement.Tools; +using Meadow.CLI.Core.Exceptions; +using Meadow.CLI.Core.Identity; +using Microsoft.Extensions.Configuration; using System; using System.Collections.Generic; using System.IO; -using System.Net.Http.Headers; using System.Net.Http; +using System.Net.Http.Headers; using System.Text; -using System.Threading.Tasks; -using Meadow.CLI.Core.Exceptions; +using System.Text.Json; using System.Threading; -using Meadow.CLI.Core.CloudServices.Messages; -using Microsoft.Extensions.Configuration; -using Meadow.CLI.Core.DeviceManagement.Tools; +using System.Threading.Tasks; namespace Meadow.CLI.Core.CloudServices { public class PackageService : CloudServiceBase { - IConfiguration _config; - IdentityManager _identityManager; + readonly IConfiguration _config; public PackageService(IConfiguration config, IdentityManager identityManager) : base(identityManager) { _config = config; - _identityManager = identityManager; } public async Task UploadPackage(string mpakPath, string orgId, string description, string host, CancellationToken cancellationToken) @@ -37,39 +35,38 @@ public async Task UploadPackage(string mpakPath, string orgId, string d { throw new ArgumentException($"Invalid path: {mpakPath}"); } - - var httpClient = await AuthenticatedHttpClient(); - using (var multipartFormContent = new MultipartFormDataContent()) + var httpClient = await GetAuthenticatedHttpClient(cancellationToken); + + using var multipartFormContent = new MultipartFormDataContent(); + + var fileStreamContent = new StreamContent(File.OpenRead(mpakPath)); + fileStreamContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + + var fi = new FileInfo(mpakPath); + var crcFileHash = await CrcTools.CalculateCrc32FileHash(mpakPath); + + dynamic payload = new { + orgId, + description = description ?? "", + crc = crcFileHash ?? "", + fileSize = fi.Length + }; + var json = JsonSerializer.Serialize(payload); + + multipartFormContent.Add(fileStreamContent, name: "file", fileName: fi.Name); + multipartFormContent.Add(new StringContent(json), "json"); + + var response = await httpClient.PostAsync($"{host}/api/packages", multipartFormContent); + if (response.IsSuccessStatusCode) + { + var package = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync()); + return package!; + } + else { - var fileStreamContent = new StreamContent(File.OpenRead(mpakPath)); - fileStreamContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); - - var fi = new FileInfo(mpakPath); - var crcFileHash = await CrcTools.CalculateCrc32FileHash(mpakPath); - - dynamic payload = new { - orgId, - description = description ?? "", - crc = crcFileHash ?? "", - fileSize = fi.Length - }; - var json = JsonSerializer.Serialize(payload); - - multipartFormContent.Add(fileStreamContent, name: "file", fileName: fi.Name); - multipartFormContent.Add(new StringContent(json), "json"); - - var response = await httpClient.PostAsync($"{host}/api/packages", multipartFormContent); - if (response.IsSuccessStatusCode) - { - var package = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync()); - return package; - } - else - { - var message = await response.Content.ReadAsStringAsync(); - throw new MeadowCloudException($"{response.StatusCode} {message}"); - } + var message = await response.Content.ReadAsStringAsync(); + throw new MeadowCloudException($"{response.StatusCode} {message}"); } } @@ -79,19 +76,12 @@ public async Task PublishPackage(string packageId, string collectionId, string m { host = _config[Constants.MEADOW_CLOUD_HOST_CONFIG_NAME]; } - - var authToken = await _identityManager.GetAccessToken(cancellationToken).ConfigureAwait(false); - if (string.IsNullOrEmpty(authToken)) - { - throw new MeadowCloudAuthException(); - } - HttpClient httpClient = new HttpClient(); - httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", authToken); + var httpClient = await GetAuthenticatedHttpClient(cancellationToken); var payload = new { metadata, collectionId }; var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); - var response = await httpClient.PostAsync($"{host}/api/packages/{packageId}/publish", content); + var response = await httpClient.PostAsync($"{host}/api/packages/{packageId}/publish", content, cancellationToken); if (!response.IsSuccessStatusCode) { @@ -106,18 +96,11 @@ public async Task> GetOrgPackages(string orgId, string host, Cance { host = _config[Constants.MEADOW_CLOUD_HOST_CONFIG_NAME]; } - - var authToken = await _identityManager.GetAccessToken(cancellationToken).ConfigureAwait(false); - if (string.IsNullOrEmpty(authToken)) - { - throw new MeadowCloudAuthException(); - } - HttpClient httpClient = new HttpClient(); - httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", authToken); + var httpClient = await GetAuthenticatedHttpClient(cancellationToken); var result = await httpClient.GetStringAsync($"{host}/api/orgs/{orgId}/packages"); - return JsonSerializer.Deserialize>(result); + return JsonSerializer.Deserialize>(result) ?? new List(); } } } diff --git a/Meadow.CLI.Core/CloudServices/UserService.cs b/Meadow.CLI.Core/CloudServices/UserService.cs index 57eb923f..967d799e 100644 --- a/Meadow.CLI.Core/CloudServices/UserService.cs +++ b/Meadow.CLI.Core/CloudServices/UserService.cs @@ -1,22 +1,16 @@ using Meadow.CLI.Core.CloudServices.Messages; using Meadow.CLI.Core.Identity; -using System; +using Microsoft.Extensions.Configuration; using System.Collections.Generic; -using System.Net.Http.Headers; -using System.Net.Http; -using System.Text; -using System.Threading; using System.Text.Json; -using System.Net; +using System.Threading; using System.Threading.Tasks; -using Meadow.CLI.Core.Exceptions; -using Microsoft.Extensions.Configuration; namespace Meadow.CLI.Core.CloudServices { public class UserService : CloudServiceBase { - IConfiguration _config; + readonly IConfiguration _config; public UserService(IConfiguration config, IdentityManager identityManager) : base(identityManager) { @@ -30,14 +24,14 @@ public async Task> GetUserOrgs(string host, CancellationToken canc host = _config[Constants.MEADOW_CLOUD_HOST_CONFIG_NAME]; } - var httpClient = await AuthenticatedHttpClient(); + var httpClient = await GetAuthenticatedHttpClient(cancellationToken); - var response = await httpClient.GetAsync($"{host}/api/users/me/orgs"); + var response = await httpClient.GetAsync($"{host}/api/users/me/orgs", cancellationToken); if (response.IsSuccessStatusCode) { var message = await response.Content.ReadAsStringAsync(); - return JsonSerializer.Deserialize>(message); + return JsonSerializer.Deserialize>(message) ?? new List(); } else { @@ -52,7 +46,7 @@ public async Task> GetUserOrgs(string host, CancellationToken canc host = _config[Constants.MEADOW_CLOUD_HOST_CONFIG_NAME]; } - var httpClient = await AuthenticatedHttpClient(); + var httpClient = await GetAuthenticatedHttpClient(cancellationToken); var response = await httpClient.GetAsync($"{host}/api/users/me"); diff --git a/Meadow.CLI/Commands/Cloud/CloudCommand.cs b/Meadow.CLI/Commands/Cloud/CloudCommand.cs new file mode 100644 index 00000000..147b80c6 --- /dev/null +++ b/Meadow.CLI/Commands/Cloud/CloudCommand.cs @@ -0,0 +1,26 @@ +using CliFx; +using CliFx.Attributes; +using CliFx.Exceptions; +using CliFx.Infrastructure; +using System.Threading.Tasks; + +namespace Meadow.CLI.Commands.Cloud +{ + [Command("cloud", Description = "Provides Meadow.Cloud service related commands.")] + public class CloudCommand : ICommand + { + public ValueTask ExecuteAsync(IConsole console) + { + throw new CommandException("Please use one of the cloud subcommands.", showHelp: true); + } + } + + [Command("cloud command", Description = "Provides command & control related commands for devices.")] + public class CloudCommandCommand : ICommand + { + public ValueTask ExecuteAsync(IConsole console) + { + throw new CommandException("Please use one of the cloud command subcommands.", showHelp: true); + } + } +} diff --git a/Meadow.CLI/Commands/Cloud/Command/JsonDocumentBindingConverter.cs b/Meadow.CLI/Commands/Cloud/Command/JsonDocumentBindingConverter.cs new file mode 100644 index 00000000..3165481b --- /dev/null +++ b/Meadow.CLI/Commands/Cloud/Command/JsonDocumentBindingConverter.cs @@ -0,0 +1,18 @@ +using CliFx.Exceptions; +using CliFx.Extensibility; +using System.Text.Json; + +namespace Meadow.CLI.Commands.Cloud.Command +{ + public class JsonDocumentBindingConverter : BindingConverter + { + public override JsonDocument Convert(string rawValue) + { + try { return JsonDocument.Parse(rawValue); } + catch (JsonException ex) + { + throw new CommandException($"Provided arguments is not valid JSON: {ex.Message}", showHelp: false, innerException: ex); + } + } + } +} diff --git a/Meadow.CLI/Commands/Cloud/Command/PublishCommand.cs b/Meadow.CLI/Commands/Cloud/Command/PublishCommand.cs new file mode 100644 index 00000000..8b7118e6 --- /dev/null +++ b/Meadow.CLI/Commands/Cloud/Command/PublishCommand.cs @@ -0,0 +1,81 @@ +using CliFx; +using CliFx.Attributes; +using CliFx.Exceptions; +using CliFx.Infrastructure; +using Meadow.CLI.Core.CloudServices; +using Meadow.CLI.Core.Exceptions; +using Meadow.CLI.Core.Identity; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using System.Text.Json; +using System.Threading.Tasks; + +namespace Meadow.CLI.Commands.Cloud.Command +{ + public enum QualityOfService + { + AtLeastOnce = 0, + AtMostOnce = 1, + ExactlyOnce = 2 + } + + [Command("cloud command publish", Description = "Publish a command to Meadow devices via the Meadow Service")] + public class PublishCommand : ICommand + { + private readonly ILogger _logger; + private readonly CommandService _commandService; + private readonly IdentityManager _identityManager; + IConfiguration _config; + + public PublishCommand(ILoggerFactory loggerFactory, CommandService commandService, IdentityManager identityManager, IConfiguration config) + { + _logger = loggerFactory.CreateLogger(); + _commandService = commandService; + _identityManager = identityManager; + _config = config; + } + + [CommandParameter(0, Description = "The name of the command", IsRequired = true, Name = "COMMAND_NAME")] + public string CommandName { get; set; } + + [CommandOption("collectionId", 'c', Description = "The target collection for publishing the command", IsRequired = true)] + public string CollectionId { get; set; } + + [CommandOption("args", 'a', Description = "The arguments for the command as a JSON string", Converter = typeof(JsonDocumentBindingConverter))] + public JsonDocument Arguments { get; set; } + + [CommandOption("qos", 'q', Description = "The MQTT-defined quality of service for the command")] + public QualityOfService QualityOfService { get; set; } + + [CommandOption("host", Description = "Optionally set a host (default is https://www.meadowcloud.co)")] + public string Host { get; set; } + + public async ValueTask ExecuteAsync(IConsole console) + { + var cancellationToken = console.RegisterCancellationHandler(); + + await Task.Yield(); + + var token = await _identityManager.GetAccessToken(cancellationToken); + if (string.IsNullOrWhiteSpace(token)) + { + throw new CommandException("You must be signed into Meadow.Cloud to execute this command. Run 'meadow cloud login' to do so."); + } + + try + { + _logger.LogInformation($"Publishing '{CommandName}' command to Meadow.Cloud. Please wait..."); + await _commandService.PublishCommandForCollection(CollectionId, CommandName, Arguments, (int)QualityOfService, Host, cancellationToken); + _logger.LogInformation("Publish command successful."); + } + catch (MeadowCloudAuthException ex) + { + throw new CommandException("You must be signed in to execute this command.", innerException: ex); + } + catch (MeadowCloudException ex) + { + throw new CommandException($"Publish command failed: {ex.Message}", innerException: ex); + } + } + } +} diff --git a/Meadow.CLI/Commands/Cloud/Package/PublishPackageCommend.cs b/Meadow.CLI/Commands/Cloud/Package/PublishPackageCommand.cs similarity index 100% rename from Meadow.CLI/Commands/Cloud/Package/PublishPackageCommend.cs rename to Meadow.CLI/Commands/Cloud/Package/PublishPackageCommand.cs diff --git a/Meadow.CLI/Meadow.CLI.csproj b/Meadow.CLI/Meadow.CLI.csproj index 0f1faa51..ee1c6f48 100644 --- a/Meadow.CLI/Meadow.CLI.csproj +++ b/Meadow.CLI/Meadow.CLI.csproj @@ -2,7 +2,7 @@ Exe - net6.0 + net6.0 true Wilderness Labs, Inc meadow diff --git a/Meadow.CLI/Program.cs b/Meadow.CLI/Program.cs index cc83ae5c..47c54e58 100644 --- a/Meadow.CLI/Program.cs +++ b/Meadow.CLI/Program.cs @@ -1,5 +1,6 @@ using CliFx; using Meadow.CLI.Commands; +using Meadow.CLI.Commands.Cloud.Command; using Meadow.CLI.Core; using Meadow.CLI.Core.CloudServices; using Meadow.CLI.Core.DeviceManagement; @@ -9,7 +10,6 @@ using Serilog; using Serilog.Events; using System; -using System.Configuration; using System.Diagnostics; using System.Linq; using System.Threading.Tasks; @@ -62,10 +62,12 @@ public static async Task Main(string[] args) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); AddCommandsAsServices(services);