From 64a4c85378a41feb2b8649a0d28f0849eea98d2f Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 26 Apr 2026 10:40:50 +0200 Subject: [PATCH 01/16] feat(cli): add CCU command group and enhance CLI with CCU backup support - Introduced `CcuCommandGroup` to group CCU-related commands in the CLI. - Enhanced `ShowDeviceDetailsCommand` with `CliCommand` attribute for better integration. - Added `ICcuBackupServiceBuilder` to support CCU backup handling. - Updated project dependencies and service registrations to include new functionality. --- .../CreativeCoders.HomeMatic.csproj | 4 ++++ .../HomeMaticServiceCollectionExtensions.cs | 2 ++ .../Ccu/CcuCommandGroup.cs | 17 +++++++++++++++++ .../ShowDetails/ShowDeviceDetailsCommand.cs | 1 + 4 files changed, 24 insertions(+) create mode 100644 source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/CcuCommandGroup.cs diff --git a/source/CreativeCoders.HomeMatic/CreativeCoders.HomeMatic.csproj b/source/CreativeCoders.HomeMatic/CreativeCoders.HomeMatic.csproj index aa6a69c..302f4d3 100644 --- a/source/CreativeCoders.HomeMatic/CreativeCoders.HomeMatic.csproj +++ b/source/CreativeCoders.HomeMatic/CreativeCoders.HomeMatic.csproj @@ -5,6 +5,10 @@ enable + + + + diff --git a/source/CreativeCoders.HomeMatic/HomeMaticServiceCollectionExtensions.cs b/source/CreativeCoders.HomeMatic/HomeMaticServiceCollectionExtensions.cs index 18a11c9..d71b4a1 100644 --- a/source/CreativeCoders.HomeMatic/HomeMaticServiceCollectionExtensions.cs +++ b/source/CreativeCoders.HomeMatic/HomeMaticServiceCollectionExtensions.cs @@ -1,3 +1,4 @@ +using CreativeCoders.HomeMatic.Backup; using CreativeCoders.HomeMatic.Core; using CreativeCoders.HomeMatic.Exporting; using CreativeCoders.HomeMatic.JsonRpc; @@ -34,6 +35,7 @@ public static IServiceCollection AddHomeMatic(this IServiceCollection services) services.TryAddTransient(); services.TryAddTransient(); services.TryAddSingleton(); + services.TryAddTransient(); return services; } diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/CcuCommandGroup.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/CcuCommandGroup.cs new file mode 100644 index 0000000..2e95906 --- /dev/null +++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/CcuCommandGroup.cs @@ -0,0 +1,17 @@ +using CreativeCoders.Cli.Core; +using CreativeCoders.HomeMatic.Tools.Cli.Commands.Ccu; + +[assembly: CliCommandGroup([CcuCommandGroup.Name], "Commands for CCU operations")] + +namespace CreativeCoders.HomeMatic.Tools.Cli.Commands.Ccu; + +/// +/// Defines the command group for CCU operations. +/// +public static class CcuCommandGroup +{ + /// + /// The name of the CCU command group. + /// + public const string Name = "ccu"; +} diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/ShowDetails/ShowDeviceDetailsCommand.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/ShowDetails/ShowDeviceDetailsCommand.cs index d9abfa6..554fff0 100644 --- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/ShowDetails/ShowDeviceDetailsCommand.cs +++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/ShowDetails/ShowDeviceDetailsCommand.cs @@ -10,6 +10,7 @@ namespace CreativeCoders.HomeMatic.Tools.Cli.Commands.Device.ShowDetails; [UsedImplicitly] +[CliCommand([DeviceCommandGroup.Name, "details"], Description = "Show details for a device")] public class ShowDeviceDetailsCommand(IAnsiConsole console, IMultiCcuClient multiCcuClient) : ICliCommand { From 99926f452b48f0c55ed387910278aca361ded44a Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 26 Apr 2026 10:54:46 +0200 Subject: [PATCH 02/16] feat(homeMatic): add HTTP client for CCU backup service - Registered a named `HttpClient` ("CcuBackup") in `HomeMaticServiceCollectionExtensions`. - Enables support for CCU backup handling via `ICcuBackupServiceBuilder`. --- .../HomeMaticServiceCollectionExtensions.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/source/CreativeCoders.HomeMatic/HomeMaticServiceCollectionExtensions.cs b/source/CreativeCoders.HomeMatic/HomeMaticServiceCollectionExtensions.cs index d71b4a1..1c8e5ad 100644 --- a/source/CreativeCoders.HomeMatic/HomeMaticServiceCollectionExtensions.cs +++ b/source/CreativeCoders.HomeMatic/HomeMaticServiceCollectionExtensions.cs @@ -35,6 +35,7 @@ public static IServiceCollection AddHomeMatic(this IServiceCollection services) services.TryAddTransient(); services.TryAddTransient(); services.TryAddSingleton(); + services.AddHttpClient("CcuBackup"); services.TryAddTransient(); return services; From d161f220b006fd4c883ac5ad8f8a9993e9716dd3 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 26 Apr 2026 10:59:03 +0200 Subject: [PATCH 03/16] feat(homeMatic, cli): add CCU backup service and CLI command - Implemented `CcuBackupService` to handle HomeMatic CCU system backups via JSON-RPC and HTTP. - Added `CcuBackupServiceBuilder` for flexible service configuration based on host and credentials. - Introduced CLI support with the `CreateBackupCommand` and `CreateBackupOptions` for managing CCU backups directly from the command line. - Extended `HomeMaticServiceCollectionExtensions` to register CCU backup services. --- .gitignore | 1 - .../Backup/CcuBackupService.cs | 161 ++++++++++++++++++ .../Backup/CcuBackupServiceBuilder.cs | 58 +++++++ .../Backup/ICcuBackupService.cs | 26 +++ .../Backup/ICcuBackupServiceBuilder.cs | 31 ++++ .../HomeMaticServiceCollectionExtensions.cs | 4 +- .../Ccu/Backup/CreateBackupCommand.cs | 62 +++++++ .../Ccu/Backup/CreateBackupOptions.cs | 23 +++ 8 files changed, 364 insertions(+), 2 deletions(-) create mode 100644 source/CreativeCoders.HomeMatic/Backup/CcuBackupService.cs create mode 100644 source/CreativeCoders.HomeMatic/Backup/CcuBackupServiceBuilder.cs create mode 100644 source/CreativeCoders.HomeMatic/Backup/ICcuBackupService.cs create mode 100644 source/CreativeCoders.HomeMatic/Backup/ICcuBackupServiceBuilder.cs create mode 100644 source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/CreateBackupCommand.cs create mode 100644 source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/CreateBackupOptions.cs diff --git a/.gitignore b/.gitignore index 19b8cbb..7d64e26 100644 --- a/.gitignore +++ b/.gitignore @@ -231,7 +231,6 @@ Generated_Code/ # to a newer Visual Studio version. Backup files are not needed, # because we have git ;-) _UpgradeReport_Files/ -Backup*/ UpgradeLog*.XML UpgradeLog*.htm ServiceFabricBackup/ diff --git a/source/CreativeCoders.HomeMatic/Backup/CcuBackupService.cs b/source/CreativeCoders.HomeMatic/Backup/CcuBackupService.cs new file mode 100644 index 0000000..6d8828b --- /dev/null +++ b/source/CreativeCoders.HomeMatic/Backup/CcuBackupService.cs @@ -0,0 +1,161 @@ +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; +using CreativeCoders.Core; + +namespace CreativeCoders.HomeMatic.Backup; + +/// +/// Creates system backups of a HomeMatic CCU by authenticating via JSON-RPC and downloading +/// the backup archive over HTTP. +/// +public class CcuBackupService : ICcuBackupService +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private readonly HttpClient _httpClient; + private readonly Uri _baseUrl; + private readonly NetworkCredential _credential; + + /// + /// Initializes a new instance of the class. + /// + /// The HTTP client used for communication with the CCU. + /// The base URL of the CCU (e.g. http://192.168.1.100). + /// The credentials used to authenticate against the CCU. + public CcuBackupService(HttpClient httpClient, Uri baseUrl, NetworkCredential credential) + { + _httpClient = Ensure.NotNull(httpClient); + _baseUrl = Ensure.NotNull(baseUrl); + _credential = Ensure.NotNull(credential); + } + + /// + public async Task CreateBackupAsync(CancellationToken cancellationToken = default) + { + var sessionId = await LoginAsync(cancellationToken).ConfigureAwait(false); + + try + { + var backupUrl = new Uri(_baseUrl, $"/config/cp_security.cgi?sid={sessionId}&action=create_backup"); + + using var backupRequest = new HttpRequestMessage(HttpMethod.Get, backupUrl); + backupRequest.Headers.Add("Cookie", $"SID={sessionId}"); + + var response = await _httpClient.SendAsync(backupRequest, HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + var contentType = response.Content.Headers.ContentType?.MediaType ?? string.Empty; + + if (contentType.StartsWith("text/", StringComparison.OrdinalIgnoreCase)) + { + var errorBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + throw new InvalidOperationException($"CCU backup failed: {errorBody.Trim()}"); + } + + var memoryStream = new MemoryStream(); + + await response.Content.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false); + + memoryStream.Position = 0; + + return memoryStream; + } + finally + { + await LogoutAsync(sessionId, cancellationToken).ConfigureAwait(false); + } + } + + /// + public async Task SaveBackupAsync(string outputFilePath, CancellationToken cancellationToken = default) + { + Ensure.IsNotNullOrWhitespace(outputFilePath); + + await using var backupStream = await CreateBackupAsync(cancellationToken).ConfigureAwait(false); + await using var fileStream = new FileStream(outputFilePath, FileMode.Create, FileAccess.Write, FileShare.None, + bufferSize: 81920, useAsync: true); + + await backupStream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); + } + + private async Task LoginAsync(CancellationToken cancellationToken) + { + var request = new JsonRpcRequest + { + Method = "Session.login", + Params = new Dictionary + { + ["username"] = _credential.UserName, + ["password"] = _credential.Password + } + }; + + var apiUrl = new Uri(_baseUrl, "/api/homematic.cgi"); + var content = new StringContent(JsonSerializer.Serialize(request, JsonOptions), + System.Text.Encoding.UTF8, "application/json"); + + var response = await _httpClient.PostAsync(apiUrl, content, cancellationToken).ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + var loginResponse = await JsonSerializer.DeserializeAsync(responseStream, JsonOptions, + cancellationToken).ConfigureAwait(false); + + if (string.IsNullOrEmpty(loginResponse?.Result)) + { + throw new InvalidOperationException("CCU login failed: no session ID received."); + } + + return loginResponse.Result; + } + + private async Task LogoutAsync(string sessionId, CancellationToken cancellationToken) + { + try + { + var request = new JsonRpcRequest + { + Method = "Session.logout", + Params = new Dictionary + { + ["_session_id_"] = sessionId + } + }; + + var apiUrl = new Uri(_baseUrl, "/api/homematic.cgi"); + var content = new StringContent(JsonSerializer.Serialize(request, JsonOptions), + System.Text.Encoding.UTF8, "application/json"); + + var response = await _httpClient.PostAsync(apiUrl, content, cancellationToken).ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + } + catch + { + // Best-effort logout — do not let logout failures mask the original operation result. + } + } + + private record JsonRpcRequest + { + [JsonPropertyName("method")] + public required string Method { get; init; } + + [JsonPropertyName("params")] + public required Dictionary Params { get; init; } + } + + private record JsonRpcResponse + { + [JsonPropertyName("result")] + public string? Result { get; init; } + } +} diff --git a/source/CreativeCoders.HomeMatic/Backup/CcuBackupServiceBuilder.cs b/source/CreativeCoders.HomeMatic/Backup/CcuBackupServiceBuilder.cs new file mode 100644 index 0000000..3fdb895 --- /dev/null +++ b/source/CreativeCoders.HomeMatic/Backup/CcuBackupServiceBuilder.cs @@ -0,0 +1,58 @@ +using System.Net; +using CreativeCoders.Core; + +namespace CreativeCoders.HomeMatic.Backup; + +/// +/// Builds instances configured for a specific CCU host and credentials. +/// +public class CcuBackupServiceBuilder(IHttpClientFactory httpClientFactory) : ICcuBackupServiceBuilder +{ + private readonly IHttpClientFactory _httpClientFactory = Ensure.NotNull(httpClientFactory); + + private string? _host; + + private NetworkCredential? _credential; + + /// + public ICcuBackupServiceBuilder ForHost(string host) + { + Ensure.IsNotNullOrWhitespace(host); + + _host = host; + + return this; + } + + /// + public ICcuBackupServiceBuilder WithCredentials(NetworkCredential credential) + { + _credential = Ensure.NotNull(credential); + + return this; + } + + /// + public ICcuBackupService Build() + { + if (_host is null) + { + throw new InvalidOperationException("No host specified."); + } + + if (_credential is null) + { + throw new InvalidOperationException("No credentials specified."); + } + + var baseUrl = new UriBuilder + { + Scheme = "http", + Host = _host + }.Uri; + + var httpClient = _httpClientFactory.CreateClient("CcuBackup"); + + return new CcuBackupService(httpClient, baseUrl, _credential); + } +} diff --git a/source/CreativeCoders.HomeMatic/Backup/ICcuBackupService.cs b/source/CreativeCoders.HomeMatic/Backup/ICcuBackupService.cs new file mode 100644 index 0000000..14fe6f4 --- /dev/null +++ b/source/CreativeCoders.HomeMatic/Backup/ICcuBackupService.cs @@ -0,0 +1,26 @@ +using JetBrains.Annotations; + +namespace CreativeCoders.HomeMatic.Backup; + +/// +/// Creates system backups of a HomeMatic CCU, equivalent to the backup function +/// available in the CCU Web UI. +/// +[PublicAPI] +public interface ICcuBackupService +{ + /// + /// Asynchronously creates a system backup of the CCU and returns it as a stream. + /// + /// A token to cancel the operation. + /// A task that yields a containing the backup archive (.tar.gz). The caller is responsible for disposing the stream. + Task CreateBackupAsync(CancellationToken cancellationToken = default); + + /// + /// Asynchronously creates a system backup of the CCU and saves it to the specified file path. + /// + /// The file path where the backup archive (.tar.gz) will be saved. + /// A token to cancel the operation. + /// A task representing the asynchronous operation. + Task SaveBackupAsync(string outputFilePath, CancellationToken cancellationToken = default); +} diff --git a/source/CreativeCoders.HomeMatic/Backup/ICcuBackupServiceBuilder.cs b/source/CreativeCoders.HomeMatic/Backup/ICcuBackupServiceBuilder.cs new file mode 100644 index 0000000..34cce1c --- /dev/null +++ b/source/CreativeCoders.HomeMatic/Backup/ICcuBackupServiceBuilder.cs @@ -0,0 +1,31 @@ +using System.Net; +using JetBrains.Annotations; + +namespace CreativeCoders.HomeMatic.Backup; + +/// +/// Builds instances configured for a specific CCU. +/// +[PublicAPI] +public interface ICcuBackupServiceBuilder +{ + /// + /// Sets the host name or IP address of the CCU to back up. + /// + /// The host name or IP address of the CCU. + /// The current builder instance for method chaining. + ICcuBackupServiceBuilder ForHost(string host); + + /// + /// Sets the credentials used to authenticate against the CCU. + /// + /// The network credentials containing user name and password. + /// The current builder instance for method chaining. + ICcuBackupServiceBuilder WithCredentials(NetworkCredential credential); + + /// + /// Creates a new from the current builder configuration. + /// + /// A configured instance. + ICcuBackupService Build(); +} diff --git a/source/CreativeCoders.HomeMatic/HomeMaticServiceCollectionExtensions.cs b/source/CreativeCoders.HomeMatic/HomeMaticServiceCollectionExtensions.cs index 1c8e5ad..197139f 100644 --- a/source/CreativeCoders.HomeMatic/HomeMaticServiceCollectionExtensions.cs +++ b/source/CreativeCoders.HomeMatic/HomeMaticServiceCollectionExtensions.cs @@ -1,3 +1,4 @@ +using System.Net.Http; using CreativeCoders.HomeMatic.Backup; using CreativeCoders.HomeMatic.Core; using CreativeCoders.HomeMatic.Exporting; @@ -35,7 +36,8 @@ public static IServiceCollection AddHomeMatic(this IServiceCollection services) services.TryAddTransient(); services.TryAddTransient(); services.TryAddSingleton(); - services.AddHttpClient("CcuBackup"); + services.AddHttpClient("CcuBackup") + .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { UseCookies = false }); services.TryAddTransient(); return services; diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/CreateBackupCommand.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/CreateBackupCommand.cs new file mode 100644 index 0000000..cdcf563 --- /dev/null +++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/CreateBackupCommand.cs @@ -0,0 +1,62 @@ +using CreativeCoders.Cli.Core; +using CreativeCoders.Core; +using CreativeCoders.HomeMatic.Backup; +using CreativeCoders.HomeMatic.Tools.Cli.Base.Connections; +using JetBrains.Annotations; +using Spectre.Console; + +namespace CreativeCoders.HomeMatic.Tools.Cli.Commands.Ccu.Backup; + +/// +/// CLI command that creates a system backup of a CCU. +/// +[UsedImplicitly] +[CliCommand([CcuCommandGroup.Name, "backup"], Description = "Create a backup of a CCU")] +public class CreateBackupCommand( + IAnsiConsole console, + ICcuConnectionsStore ccuConnectionsStore, + ICcuBackupServiceBuilder ccuBackupServiceBuilder) + : ICliCommand +{ + private readonly IAnsiConsole _console = Ensure.NotNull(console); + + private readonly ICcuConnectionsStore _ccuConnectionsStore = Ensure.NotNull(ccuConnectionsStore); + + private readonly ICcuBackupServiceBuilder _ccuBackupServiceBuilder = Ensure.NotNull(ccuBackupServiceBuilder); + + /// + public async Task ExecuteAsync(CreateBackupOptions options) + { + var connections = await _ccuConnectionsStore.GetConnectionsAsync().ConfigureAwait(false); + + var connection = connections.FirstOrDefault( + x => string.Equals(x.Name, options.ConnectionName, StringComparison.OrdinalIgnoreCase)); + + if (connection is null) + { + _console.MarkupLine($"[bold red]Connection '{options.ConnectionName}' not found[/]"); + return -1; + } + + var credentials = _ccuConnectionsStore.GetCredentials(connection); + + var backupService = _ccuBackupServiceBuilder + .ForHost(connection.Url.Host) + .WithCredentials(credentials) + .Build(); + + var outputFilePath = options.OutputFilePath + ?? $"backup_{connection.Name}_{DateTime.Now:yyyyMMdd_HHmmss}.tar.gz"; + + await _console.Status() + .StartAsync($"Creating backup for '{connection.Name}'...", async _ => + { + await backupService.SaveBackupAsync(outputFilePath).ConfigureAwait(false); + }) + .ConfigureAwait(false); + + _console.MarkupLine($"[bold green]Backup saved to '{outputFilePath}'[/]"); + + return CommandResult.Success; + } +} diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/CreateBackupOptions.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/CreateBackupOptions.cs new file mode 100644 index 0000000..931f7cb --- /dev/null +++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/CreateBackupOptions.cs @@ -0,0 +1,23 @@ +using CreativeCoders.SysConsole.Cli.Parsing; +using JetBrains.Annotations; + +namespace CreativeCoders.HomeMatic.Tools.Cli.Commands.Ccu.Backup; + +/// +/// Options for the CCU backup command. +/// +[UsedImplicitly] +public class CreateBackupOptions +{ + /// + /// Gets or sets the name of the CCU connection to back up. + /// + [OptionValue(0, IsRequired = true)] + public string ConnectionName { get; set; } = string.Empty; + + /// + /// Gets or sets the output file path for the backup archive. When not specified, a default file name is generated. + /// + [OptionParameter('o', "output", HelpText = "Output file path for the backup archive (.tar.gz)")] + public string? OutputFilePath { get; set; } +} From c376123b3342c276a906158c12b70969cda29bee Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 26 Apr 2026 11:44:32 +0200 Subject: [PATCH 04/16] refactor(homeMatic): simplify JSON-RPC `Params` and add `Id` property to requests --- .../Backup/CcuBackupService.cs | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/source/CreativeCoders.HomeMatic/Backup/CcuBackupService.cs b/source/CreativeCoders.HomeMatic/Backup/CcuBackupService.cs index 6d8828b..dc75ee8 100644 --- a/source/CreativeCoders.HomeMatic/Backup/CcuBackupService.cs +++ b/source/CreativeCoders.HomeMatic/Backup/CcuBackupService.cs @@ -89,12 +89,9 @@ private async Task LoginAsync(CancellationToken cancellationToken) { var request = new JsonRpcRequest { + Id = Environment.TickCount, Method = "Session.login", - Params = new Dictionary - { - ["username"] = _credential.UserName, - ["password"] = _credential.Password - } + Params = ["username", _credential.UserName, "password", _credential.Password] }; var apiUrl = new Uri(_baseUrl, "/api/homematic.cgi"); @@ -123,11 +120,9 @@ private async Task LogoutAsync(string sessionId, CancellationToken cancellationT { var request = new JsonRpcRequest { + Id = Environment.TickCount, Method = "Session.logout", - Params = new Dictionary - { - ["_session_id_"] = sessionId - } + Params = ["_session_id_", sessionId] }; var apiUrl = new Uri(_baseUrl, "/api/homematic.cgi"); @@ -146,11 +141,14 @@ private async Task LogoutAsync(string sessionId, CancellationToken cancellationT private record JsonRpcRequest { + [JsonPropertyName("id")] + public required int Id { get; init; } + [JsonPropertyName("method")] public required string Method { get; init; } [JsonPropertyName("params")] - public required Dictionary Params { get; init; } + public required object?[] Params { get; init; } } private record JsonRpcResponse From bb5a4d4ed06f61b6243c036cd70d7d3905c57941 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 26 Apr 2026 15:33:09 +0200 Subject: [PATCH 05/16] refactor(homeMatic): remove CCU backup service and CLI command - Deleted `CcuBackupService`, `CcuBackupServiceBuilder`, and related interfaces. - Removed `CreateBackupCommand`, `CreateBackupOptions`, and `CcuCommandGroup`. - Updated `HomeMaticServiceCollectionExtensions` to exclude CCU backup registration. --- .../Backup/CcuBackupService.cs | 159 ------------------ .../Backup/CcuBackupServiceBuilder.cs | 58 ------- .../Backup/ICcuBackupService.cs | 26 --- .../Backup/ICcuBackupServiceBuilder.cs | 31 ---- .../HomeMaticServiceCollectionExtensions.cs | 5 - .../Ccu/Backup/CreateBackupCommand.cs | 62 ------- .../Ccu/Backup/CreateBackupOptions.cs | 23 --- .../Ccu/CcuCommandGroup.cs | 17 -- 8 files changed, 381 deletions(-) delete mode 100644 source/CreativeCoders.HomeMatic/Backup/CcuBackupService.cs delete mode 100644 source/CreativeCoders.HomeMatic/Backup/CcuBackupServiceBuilder.cs delete mode 100644 source/CreativeCoders.HomeMatic/Backup/ICcuBackupService.cs delete mode 100644 source/CreativeCoders.HomeMatic/Backup/ICcuBackupServiceBuilder.cs delete mode 100644 source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/CreateBackupCommand.cs delete mode 100644 source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/CreateBackupOptions.cs delete mode 100644 source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/CcuCommandGroup.cs diff --git a/source/CreativeCoders.HomeMatic/Backup/CcuBackupService.cs b/source/CreativeCoders.HomeMatic/Backup/CcuBackupService.cs deleted file mode 100644 index dc75ee8..0000000 --- a/source/CreativeCoders.HomeMatic/Backup/CcuBackupService.cs +++ /dev/null @@ -1,159 +0,0 @@ -using System.Net; -using System.Text.Json; -using System.Text.Json.Serialization; -using CreativeCoders.Core; - -namespace CreativeCoders.HomeMatic.Backup; - -/// -/// Creates system backups of a HomeMatic CCU by authenticating via JSON-RPC and downloading -/// the backup archive over HTTP. -/// -public class CcuBackupService : ICcuBackupService -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }; - - private readonly HttpClient _httpClient; - private readonly Uri _baseUrl; - private readonly NetworkCredential _credential; - - /// - /// Initializes a new instance of the class. - /// - /// The HTTP client used for communication with the CCU. - /// The base URL of the CCU (e.g. http://192.168.1.100). - /// The credentials used to authenticate against the CCU. - public CcuBackupService(HttpClient httpClient, Uri baseUrl, NetworkCredential credential) - { - _httpClient = Ensure.NotNull(httpClient); - _baseUrl = Ensure.NotNull(baseUrl); - _credential = Ensure.NotNull(credential); - } - - /// - public async Task CreateBackupAsync(CancellationToken cancellationToken = default) - { - var sessionId = await LoginAsync(cancellationToken).ConfigureAwait(false); - - try - { - var backupUrl = new Uri(_baseUrl, $"/config/cp_security.cgi?sid={sessionId}&action=create_backup"); - - using var backupRequest = new HttpRequestMessage(HttpMethod.Get, backupUrl); - backupRequest.Headers.Add("Cookie", $"SID={sessionId}"); - - var response = await _httpClient.SendAsync(backupRequest, HttpCompletionOption.ResponseHeadersRead, - cancellationToken).ConfigureAwait(false); - - response.EnsureSuccessStatusCode(); - - var contentType = response.Content.Headers.ContentType?.MediaType ?? string.Empty; - - if (contentType.StartsWith("text/", StringComparison.OrdinalIgnoreCase)) - { - var errorBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); - throw new InvalidOperationException($"CCU backup failed: {errorBody.Trim()}"); - } - - var memoryStream = new MemoryStream(); - - await response.Content.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false); - - memoryStream.Position = 0; - - return memoryStream; - } - finally - { - await LogoutAsync(sessionId, cancellationToken).ConfigureAwait(false); - } - } - - /// - public async Task SaveBackupAsync(string outputFilePath, CancellationToken cancellationToken = default) - { - Ensure.IsNotNullOrWhitespace(outputFilePath); - - await using var backupStream = await CreateBackupAsync(cancellationToken).ConfigureAwait(false); - await using var fileStream = new FileStream(outputFilePath, FileMode.Create, FileAccess.Write, FileShare.None, - bufferSize: 81920, useAsync: true); - - await backupStream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); - } - - private async Task LoginAsync(CancellationToken cancellationToken) - { - var request = new JsonRpcRequest - { - Id = Environment.TickCount, - Method = "Session.login", - Params = ["username", _credential.UserName, "password", _credential.Password] - }; - - var apiUrl = new Uri(_baseUrl, "/api/homematic.cgi"); - var content = new StringContent(JsonSerializer.Serialize(request, JsonOptions), - System.Text.Encoding.UTF8, "application/json"); - - var response = await _httpClient.PostAsync(apiUrl, content, cancellationToken).ConfigureAwait(false); - - response.EnsureSuccessStatusCode(); - - var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - var loginResponse = await JsonSerializer.DeserializeAsync(responseStream, JsonOptions, - cancellationToken).ConfigureAwait(false); - - if (string.IsNullOrEmpty(loginResponse?.Result)) - { - throw new InvalidOperationException("CCU login failed: no session ID received."); - } - - return loginResponse.Result; - } - - private async Task LogoutAsync(string sessionId, CancellationToken cancellationToken) - { - try - { - var request = new JsonRpcRequest - { - Id = Environment.TickCount, - Method = "Session.logout", - Params = ["_session_id_", sessionId] - }; - - var apiUrl = new Uri(_baseUrl, "/api/homematic.cgi"); - var content = new StringContent(JsonSerializer.Serialize(request, JsonOptions), - System.Text.Encoding.UTF8, "application/json"); - - var response = await _httpClient.PostAsync(apiUrl, content, cancellationToken).ConfigureAwait(false); - - response.EnsureSuccessStatusCode(); - } - catch - { - // Best-effort logout — do not let logout failures mask the original operation result. - } - } - - private record JsonRpcRequest - { - [JsonPropertyName("id")] - public required int Id { get; init; } - - [JsonPropertyName("method")] - public required string Method { get; init; } - - [JsonPropertyName("params")] - public required object?[] Params { get; init; } - } - - private record JsonRpcResponse - { - [JsonPropertyName("result")] - public string? Result { get; init; } - } -} diff --git a/source/CreativeCoders.HomeMatic/Backup/CcuBackupServiceBuilder.cs b/source/CreativeCoders.HomeMatic/Backup/CcuBackupServiceBuilder.cs deleted file mode 100644 index 3fdb895..0000000 --- a/source/CreativeCoders.HomeMatic/Backup/CcuBackupServiceBuilder.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System.Net; -using CreativeCoders.Core; - -namespace CreativeCoders.HomeMatic.Backup; - -/// -/// Builds instances configured for a specific CCU host and credentials. -/// -public class CcuBackupServiceBuilder(IHttpClientFactory httpClientFactory) : ICcuBackupServiceBuilder -{ - private readonly IHttpClientFactory _httpClientFactory = Ensure.NotNull(httpClientFactory); - - private string? _host; - - private NetworkCredential? _credential; - - /// - public ICcuBackupServiceBuilder ForHost(string host) - { - Ensure.IsNotNullOrWhitespace(host); - - _host = host; - - return this; - } - - /// - public ICcuBackupServiceBuilder WithCredentials(NetworkCredential credential) - { - _credential = Ensure.NotNull(credential); - - return this; - } - - /// - public ICcuBackupService Build() - { - if (_host is null) - { - throw new InvalidOperationException("No host specified."); - } - - if (_credential is null) - { - throw new InvalidOperationException("No credentials specified."); - } - - var baseUrl = new UriBuilder - { - Scheme = "http", - Host = _host - }.Uri; - - var httpClient = _httpClientFactory.CreateClient("CcuBackup"); - - return new CcuBackupService(httpClient, baseUrl, _credential); - } -} diff --git a/source/CreativeCoders.HomeMatic/Backup/ICcuBackupService.cs b/source/CreativeCoders.HomeMatic/Backup/ICcuBackupService.cs deleted file mode 100644 index 14fe6f4..0000000 --- a/source/CreativeCoders.HomeMatic/Backup/ICcuBackupService.cs +++ /dev/null @@ -1,26 +0,0 @@ -using JetBrains.Annotations; - -namespace CreativeCoders.HomeMatic.Backup; - -/// -/// Creates system backups of a HomeMatic CCU, equivalent to the backup function -/// available in the CCU Web UI. -/// -[PublicAPI] -public interface ICcuBackupService -{ - /// - /// Asynchronously creates a system backup of the CCU and returns it as a stream. - /// - /// A token to cancel the operation. - /// A task that yields a containing the backup archive (.tar.gz). The caller is responsible for disposing the stream. - Task CreateBackupAsync(CancellationToken cancellationToken = default); - - /// - /// Asynchronously creates a system backup of the CCU and saves it to the specified file path. - /// - /// The file path where the backup archive (.tar.gz) will be saved. - /// A token to cancel the operation. - /// A task representing the asynchronous operation. - Task SaveBackupAsync(string outputFilePath, CancellationToken cancellationToken = default); -} diff --git a/source/CreativeCoders.HomeMatic/Backup/ICcuBackupServiceBuilder.cs b/source/CreativeCoders.HomeMatic/Backup/ICcuBackupServiceBuilder.cs deleted file mode 100644 index 34cce1c..0000000 --- a/source/CreativeCoders.HomeMatic/Backup/ICcuBackupServiceBuilder.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Net; -using JetBrains.Annotations; - -namespace CreativeCoders.HomeMatic.Backup; - -/// -/// Builds instances configured for a specific CCU. -/// -[PublicAPI] -public interface ICcuBackupServiceBuilder -{ - /// - /// Sets the host name or IP address of the CCU to back up. - /// - /// The host name or IP address of the CCU. - /// The current builder instance for method chaining. - ICcuBackupServiceBuilder ForHost(string host); - - /// - /// Sets the credentials used to authenticate against the CCU. - /// - /// The network credentials containing user name and password. - /// The current builder instance for method chaining. - ICcuBackupServiceBuilder WithCredentials(NetworkCredential credential); - - /// - /// Creates a new from the current builder configuration. - /// - /// A configured instance. - ICcuBackupService Build(); -} diff --git a/source/CreativeCoders.HomeMatic/HomeMaticServiceCollectionExtensions.cs b/source/CreativeCoders.HomeMatic/HomeMaticServiceCollectionExtensions.cs index 197139f..18a11c9 100644 --- a/source/CreativeCoders.HomeMatic/HomeMaticServiceCollectionExtensions.cs +++ b/source/CreativeCoders.HomeMatic/HomeMaticServiceCollectionExtensions.cs @@ -1,5 +1,3 @@ -using System.Net.Http; -using CreativeCoders.HomeMatic.Backup; using CreativeCoders.HomeMatic.Core; using CreativeCoders.HomeMatic.Exporting; using CreativeCoders.HomeMatic.JsonRpc; @@ -36,9 +34,6 @@ public static IServiceCollection AddHomeMatic(this IServiceCollection services) services.TryAddTransient(); services.TryAddTransient(); services.TryAddSingleton(); - services.AddHttpClient("CcuBackup") - .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { UseCookies = false }); - services.TryAddTransient(); return services; } diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/CreateBackupCommand.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/CreateBackupCommand.cs deleted file mode 100644 index cdcf563..0000000 --- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/CreateBackupCommand.cs +++ /dev/null @@ -1,62 +0,0 @@ -using CreativeCoders.Cli.Core; -using CreativeCoders.Core; -using CreativeCoders.HomeMatic.Backup; -using CreativeCoders.HomeMatic.Tools.Cli.Base.Connections; -using JetBrains.Annotations; -using Spectre.Console; - -namespace CreativeCoders.HomeMatic.Tools.Cli.Commands.Ccu.Backup; - -/// -/// CLI command that creates a system backup of a CCU. -/// -[UsedImplicitly] -[CliCommand([CcuCommandGroup.Name, "backup"], Description = "Create a backup of a CCU")] -public class CreateBackupCommand( - IAnsiConsole console, - ICcuConnectionsStore ccuConnectionsStore, - ICcuBackupServiceBuilder ccuBackupServiceBuilder) - : ICliCommand -{ - private readonly IAnsiConsole _console = Ensure.NotNull(console); - - private readonly ICcuConnectionsStore _ccuConnectionsStore = Ensure.NotNull(ccuConnectionsStore); - - private readonly ICcuBackupServiceBuilder _ccuBackupServiceBuilder = Ensure.NotNull(ccuBackupServiceBuilder); - - /// - public async Task ExecuteAsync(CreateBackupOptions options) - { - var connections = await _ccuConnectionsStore.GetConnectionsAsync().ConfigureAwait(false); - - var connection = connections.FirstOrDefault( - x => string.Equals(x.Name, options.ConnectionName, StringComparison.OrdinalIgnoreCase)); - - if (connection is null) - { - _console.MarkupLine($"[bold red]Connection '{options.ConnectionName}' not found[/]"); - return -1; - } - - var credentials = _ccuConnectionsStore.GetCredentials(connection); - - var backupService = _ccuBackupServiceBuilder - .ForHost(connection.Url.Host) - .WithCredentials(credentials) - .Build(); - - var outputFilePath = options.OutputFilePath - ?? $"backup_{connection.Name}_{DateTime.Now:yyyyMMdd_HHmmss}.tar.gz"; - - await _console.Status() - .StartAsync($"Creating backup for '{connection.Name}'...", async _ => - { - await backupService.SaveBackupAsync(outputFilePath).ConfigureAwait(false); - }) - .ConfigureAwait(false); - - _console.MarkupLine($"[bold green]Backup saved to '{outputFilePath}'[/]"); - - return CommandResult.Success; - } -} diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/CreateBackupOptions.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/CreateBackupOptions.cs deleted file mode 100644 index 931f7cb..0000000 --- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/CreateBackupOptions.cs +++ /dev/null @@ -1,23 +0,0 @@ -using CreativeCoders.SysConsole.Cli.Parsing; -using JetBrains.Annotations; - -namespace CreativeCoders.HomeMatic.Tools.Cli.Commands.Ccu.Backup; - -/// -/// Options for the CCU backup command. -/// -[UsedImplicitly] -public class CreateBackupOptions -{ - /// - /// Gets or sets the name of the CCU connection to back up. - /// - [OptionValue(0, IsRequired = true)] - public string ConnectionName { get; set; } = string.Empty; - - /// - /// Gets or sets the output file path for the backup archive. When not specified, a default file name is generated. - /// - [OptionParameter('o', "output", HelpText = "Output file path for the backup archive (.tar.gz)")] - public string? OutputFilePath { get; set; } -} diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/CcuCommandGroup.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/CcuCommandGroup.cs deleted file mode 100644 index 2e95906..0000000 --- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/CcuCommandGroup.cs +++ /dev/null @@ -1,17 +0,0 @@ -using CreativeCoders.Cli.Core; -using CreativeCoders.HomeMatic.Tools.Cli.Commands.Ccu; - -[assembly: CliCommandGroup([CcuCommandGroup.Name], "Commands for CCU operations")] - -namespace CreativeCoders.HomeMatic.Tools.Cli.Commands.Ccu; - -/// -/// Defines the command group for CCU operations. -/// -public static class CcuCommandGroup -{ - /// - /// The name of the CCU command group. - /// - public const string Name = "ccu"; -} From 956a68559400a2842235ec25568539086f595a79 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 26 Apr 2026 15:54:38 +0200 Subject: [PATCH 06/16] refactor(homeMatic): remove unused HTTP package reference, `Dispose` method, and redundant device export logic --- .../CreativeCoders.HomeMatic.csproj | 4 ---- .../FirmwareBackup/FirmwareBackupResult.cs | 8 +------- .../Device/Export/ExportDevicesCommand.cs | 15 --------------- 3 files changed, 1 insertion(+), 26 deletions(-) diff --git a/source/CreativeCoders.HomeMatic/CreativeCoders.HomeMatic.csproj b/source/CreativeCoders.HomeMatic/CreativeCoders.HomeMatic.csproj index 3890119..ce48c09 100644 --- a/source/CreativeCoders.HomeMatic/CreativeCoders.HomeMatic.csproj +++ b/source/CreativeCoders.HomeMatic/CreativeCoders.HomeMatic.csproj @@ -15,8 +15,4 @@ - - - - diff --git a/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupResult.cs b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupResult.cs index 67e016a..84c7716 100644 --- a/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupResult.cs +++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupResult.cs @@ -11,7 +11,7 @@ namespace CreativeCoders.HomeMatic.FirmwareBackup; /// Always dispose the result to release the connection. /// [PublicAPI] -public sealed class FirmwareBackupResult : IAsyncDisposable, IDisposable +public sealed class FirmwareBackupResult : IAsyncDisposable { private readonly IAsyncDisposable[] _additionalResources; @@ -62,10 +62,4 @@ public async ValueTask DisposeAsync() await resource.DisposeAsync().ConfigureAwait(false); } } - - /// - public void Dispose() - { - DisposeAsync().AsTask().GetAwaiter().GetResult(); - } } diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/Export/ExportDevicesCommand.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/Export/ExportDevicesCommand.cs index 069ca65..3fda6e1 100644 --- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/Export/ExportDevicesCommand.cs +++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/Export/ExportDevicesCommand.cs @@ -22,21 +22,6 @@ protected override object TransformData(ICompleteCcuDevice device) { WriteIndented = true }); - return new - { - Device = device.DeviceData, - Channels = device.Channels.Select(x => - { - var channel = new - { - Info = x.ChannelData, - ParamSets = x.ParamSetValues - }; - - return channel; - }), - ParamSets = device.ParamSetValues - }; } protected override Task LoadDataAsync(IMultiCcuClient ccuClient, ExportDevicesOptions options) From fd8aa20174702f24e6b5736111750ffe1caf8edc Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 26 Apr 2026 19:48:42 +0200 Subject: [PATCH 07/16] refactor(cli): remove outdated CLI command and update base command structure - Deleted deprecated `CliBaseCommand`, `CliCommandBase`, and associated CLI command implementations. - Introduced `DataOutputCommandBase` for a streamlined and reusable command structure. - Added new tests in `CreativeCoders.HomeMatic.Tools.Cli.Base.Tests` to validate `DataOutputCommandBase` behavior and functionality. --- Directory.Packages.props | 1 + HomeMatic.sln | 115 +++++++++- .../CliBaseServiceCollectionExtensions.cs | 9 +- .../Commanding/CliBaseCommand.cs | 22 -- .../Commanding/CliCommandBase.cs | 12 -- .../Commanding/CliCommandExecutor.cs | 35 ---- .../Commanding/ICliCommandExecutor.cs | 13 -- .../Commanding/IHomeMaticCliCommand.cs | 6 - .../IHomeMaticCliCommandWithOptions.cs | 7 - .../Commands/DataOutputCommandBase.cs | 127 ++++++++++++ .../Commands/DataOutputFormat.cs | 22 ++ .../Commands/IDataOutputOptions.cs | 18 ++ .../Commands/Output/DataOutputWriter.cs | 75 +++++++ .../Commands/Output/IDataOutputWriter.cs | 24 +++ .../Serialization/DataSerializerFactory.cs | 40 ++++ .../Commands/Serialization/IDataSerializer.cs | 19 ++ .../Serialization/IDataSerializerFactory.cs | 14 ++ .../Serialization/JsonDataSerializer.cs | 30 +++ .../Serialization/YamlDataSerializer.cs | 27 +++ ...tiveCoders.HomeMatic.Tools.Cli.Base.csproj | 1 + .../Test/TestCommand.cs | 53 ----- .../DataOutputCommandBaseAdditionalTests.cs | 103 +++++++++ .../Commands/DataOutputCommandBaseTests.cs | 196 ++++++++++++++++++ .../Commands/Output/DataOutputWriterTests.cs | 194 +++++++++++++++++ .../DataSerializerFactoryTests.cs | 96 +++++++++ .../Serialization/JsonDataSerializerTests.cs | 105 ++++++++++ .../Serialization/YamlDataSerializerTests.cs | 104 ++++++++++ ...ders.HomeMatic.Tools.Cli.Base.Tests.csproj | 36 ++++ 28 files changed, 1350 insertions(+), 154 deletions(-) delete mode 100644 source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/CliBaseCommand.cs delete mode 100644 source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/CliCommandBase.cs delete mode 100644 source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/CliCommandExecutor.cs delete mode 100644 source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/ICliCommandExecutor.cs delete mode 100644 source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/IHomeMaticCliCommand.cs delete mode 100644 source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/IHomeMaticCliCommandWithOptions.cs create mode 100644 source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/DataOutputCommandBase.cs create mode 100644 source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/DataOutputFormat.cs create mode 100644 source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/IDataOutputOptions.cs create mode 100644 source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Output/DataOutputWriter.cs create mode 100644 source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Output/IDataOutputWriter.cs create mode 100644 source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Serialization/DataSerializerFactory.cs create mode 100644 source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Serialization/IDataSerializer.cs create mode 100644 source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Serialization/IDataSerializerFactory.cs create mode 100644 source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Serialization/JsonDataSerializer.cs create mode 100644 source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Serialization/YamlDataSerializer.cs delete mode 100644 source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Test/TestCommand.cs create mode 100644 tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/DataOutputCommandBaseAdditionalTests.cs create mode 100644 tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/DataOutputCommandBaseTests.cs create mode 100644 tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/Output/DataOutputWriterTests.cs create mode 100644 tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/Serialization/DataSerializerFactoryTests.cs create mode 100644 tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/Serialization/JsonDataSerializerTests.cs create mode 100644 tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/Serialization/YamlDataSerializerTests.cs create mode 100644 tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests.csproj diff --git a/Directory.Packages.props b/Directory.Packages.props index 0d9d967..93b3b95 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -24,5 +24,6 @@ + \ No newline at end of file diff --git a/HomeMatic.sln b/HomeMatic.sln index 9b7c40b..15c4af7 100644 --- a/HomeMatic.sln +++ b/HomeMatic.sln @@ -80,66 +80,172 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__docs", "__docs", "{246443 docs\HomeMatic-XmlRpc.md = docs\HomeMatic-XmlRpc.md EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.HomeMatic.Tools.Cli.Base.Tests", "tests\CreativeCoders.HomeMatic.Tools.Cli.Base.Tests\CreativeCoders.HomeMatic.Tools.Cli.Base.Tests.csproj", "{E3F3D28A-919C-4283-AEBD-A915A3EFE047}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {33773A69-F53A-4BC8-B3B9-564C8C9B0E23}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {33773A69-F53A-4BC8-B3B9-564C8C9B0E23}.Debug|Any CPU.Build.0 = Debug|Any CPU + {33773A69-F53A-4BC8-B3B9-564C8C9B0E23}.Debug|x64.ActiveCfg = Debug|Any CPU + {33773A69-F53A-4BC8-B3B9-564C8C9B0E23}.Debug|x64.Build.0 = Debug|Any CPU + {33773A69-F53A-4BC8-B3B9-564C8C9B0E23}.Debug|x86.ActiveCfg = Debug|Any CPU + {33773A69-F53A-4BC8-B3B9-564C8C9B0E23}.Debug|x86.Build.0 = Debug|Any CPU {33773A69-F53A-4BC8-B3B9-564C8C9B0E23}.Release|Any CPU.ActiveCfg = Release|Any CPU {33773A69-F53A-4BC8-B3B9-564C8C9B0E23}.Release|Any CPU.Build.0 = Release|Any CPU + {33773A69-F53A-4BC8-B3B9-564C8C9B0E23}.Release|x64.ActiveCfg = Release|Any CPU + {33773A69-F53A-4BC8-B3B9-564C8C9B0E23}.Release|x64.Build.0 = Release|Any CPU + {33773A69-F53A-4BC8-B3B9-564C8C9B0E23}.Release|x86.ActiveCfg = Release|Any CPU + {33773A69-F53A-4BC8-B3B9-564C8C9B0E23}.Release|x86.Build.0 = Release|Any CPU {C8E5E8C0-FAA5-49D3-B121-F5E5BBF7C0EF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C8E5E8C0-FAA5-49D3-B121-F5E5BBF7C0EF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C8E5E8C0-FAA5-49D3-B121-F5E5BBF7C0EF}.Debug|x64.ActiveCfg = Debug|Any CPU + {C8E5E8C0-FAA5-49D3-B121-F5E5BBF7C0EF}.Debug|x64.Build.0 = Debug|Any CPU + {C8E5E8C0-FAA5-49D3-B121-F5E5BBF7C0EF}.Debug|x86.ActiveCfg = Debug|Any CPU + {C8E5E8C0-FAA5-49D3-B121-F5E5BBF7C0EF}.Debug|x86.Build.0 = Debug|Any CPU {C8E5E8C0-FAA5-49D3-B121-F5E5BBF7C0EF}.Release|Any CPU.ActiveCfg = Release|Any CPU {C8E5E8C0-FAA5-49D3-B121-F5E5BBF7C0EF}.Release|Any CPU.Build.0 = Release|Any CPU + {C8E5E8C0-FAA5-49D3-B121-F5E5BBF7C0EF}.Release|x64.ActiveCfg = Release|Any CPU + {C8E5E8C0-FAA5-49D3-B121-F5E5BBF7C0EF}.Release|x64.Build.0 = Release|Any CPU + {C8E5E8C0-FAA5-49D3-B121-F5E5BBF7C0EF}.Release|x86.ActiveCfg = Release|Any CPU + {C8E5E8C0-FAA5-49D3-B121-F5E5BBF7C0EF}.Release|x86.Build.0 = Release|Any CPU {5F02B2B8-FC8C-44D8-A0CD-E9E31115EAB5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5F02B2B8-FC8C-44D8-A0CD-E9E31115EAB5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5F02B2B8-FC8C-44D8-A0CD-E9E31115EAB5}.Debug|x64.ActiveCfg = Debug|Any CPU + {5F02B2B8-FC8C-44D8-A0CD-E9E31115EAB5}.Debug|x64.Build.0 = Debug|Any CPU + {5F02B2B8-FC8C-44D8-A0CD-E9E31115EAB5}.Debug|x86.ActiveCfg = Debug|Any CPU + {5F02B2B8-FC8C-44D8-A0CD-E9E31115EAB5}.Debug|x86.Build.0 = Debug|Any CPU {5F02B2B8-FC8C-44D8-A0CD-E9E31115EAB5}.Release|Any CPU.ActiveCfg = Release|Any CPU {5F02B2B8-FC8C-44D8-A0CD-E9E31115EAB5}.Release|Any CPU.Build.0 = Release|Any CPU + {5F02B2B8-FC8C-44D8-A0CD-E9E31115EAB5}.Release|x64.ActiveCfg = Release|Any CPU + {5F02B2B8-FC8C-44D8-A0CD-E9E31115EAB5}.Release|x64.Build.0 = Release|Any CPU + {5F02B2B8-FC8C-44D8-A0CD-E9E31115EAB5}.Release|x86.ActiveCfg = Release|Any CPU + {5F02B2B8-FC8C-44D8-A0CD-E9E31115EAB5}.Release|x86.Build.0 = Release|Any CPU {1DB2232F-17CF-40AF-9190-09441C860BEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1DB2232F-17CF-40AF-9190-09441C860BEB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1DB2232F-17CF-40AF-9190-09441C860BEB}.Debug|x64.ActiveCfg = Debug|Any CPU + {1DB2232F-17CF-40AF-9190-09441C860BEB}.Debug|x64.Build.0 = Debug|Any CPU + {1DB2232F-17CF-40AF-9190-09441C860BEB}.Debug|x86.ActiveCfg = Debug|Any CPU + {1DB2232F-17CF-40AF-9190-09441C860BEB}.Debug|x86.Build.0 = Debug|Any CPU {1DB2232F-17CF-40AF-9190-09441C860BEB}.Release|Any CPU.ActiveCfg = Release|Any CPU {1DB2232F-17CF-40AF-9190-09441C860BEB}.Release|Any CPU.Build.0 = Release|Any CPU + {1DB2232F-17CF-40AF-9190-09441C860BEB}.Release|x64.ActiveCfg = Release|Any CPU + {1DB2232F-17CF-40AF-9190-09441C860BEB}.Release|x64.Build.0 = Release|Any CPU + {1DB2232F-17CF-40AF-9190-09441C860BEB}.Release|x86.ActiveCfg = Release|Any CPU + {1DB2232F-17CF-40AF-9190-09441C860BEB}.Release|x86.Build.0 = Release|Any CPU {15C3A03E-F2A5-4497-B0DE-A9EB73C9E6AF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {15C3A03E-F2A5-4497-B0DE-A9EB73C9E6AF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {15C3A03E-F2A5-4497-B0DE-A9EB73C9E6AF}.Debug|x64.ActiveCfg = Debug|Any CPU + {15C3A03E-F2A5-4497-B0DE-A9EB73C9E6AF}.Debug|x64.Build.0 = Debug|Any CPU + {15C3A03E-F2A5-4497-B0DE-A9EB73C9E6AF}.Debug|x86.ActiveCfg = Debug|Any CPU + {15C3A03E-F2A5-4497-B0DE-A9EB73C9E6AF}.Debug|x86.Build.0 = Debug|Any CPU {15C3A03E-F2A5-4497-B0DE-A9EB73C9E6AF}.Release|Any CPU.ActiveCfg = Release|Any CPU {15C3A03E-F2A5-4497-B0DE-A9EB73C9E6AF}.Release|Any CPU.Build.0 = Release|Any CPU + {15C3A03E-F2A5-4497-B0DE-A9EB73C9E6AF}.Release|x64.ActiveCfg = Release|Any CPU + {15C3A03E-F2A5-4497-B0DE-A9EB73C9E6AF}.Release|x64.Build.0 = Release|Any CPU + {15C3A03E-F2A5-4497-B0DE-A9EB73C9E6AF}.Release|x86.ActiveCfg = Release|Any CPU + {15C3A03E-F2A5-4497-B0DE-A9EB73C9E6AF}.Release|x86.Build.0 = Release|Any CPU {91834652-D1F2-4563-B684-AAF776E6B341}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {91834652-D1F2-4563-B684-AAF776E6B341}.Debug|Any CPU.Build.0 = Debug|Any CPU + {91834652-D1F2-4563-B684-AAF776E6B341}.Debug|x64.ActiveCfg = Debug|Any CPU + {91834652-D1F2-4563-B684-AAF776E6B341}.Debug|x64.Build.0 = Debug|Any CPU + {91834652-D1F2-4563-B684-AAF776E6B341}.Debug|x86.ActiveCfg = Debug|Any CPU + {91834652-D1F2-4563-B684-AAF776E6B341}.Debug|x86.Build.0 = Debug|Any CPU {91834652-D1F2-4563-B684-AAF776E6B341}.Release|Any CPU.ActiveCfg = Release|Any CPU {91834652-D1F2-4563-B684-AAF776E6B341}.Release|Any CPU.Build.0 = Release|Any CPU + {91834652-D1F2-4563-B684-AAF776E6B341}.Release|x64.ActiveCfg = Release|Any CPU + {91834652-D1F2-4563-B684-AAF776E6B341}.Release|x64.Build.0 = Release|Any CPU + {91834652-D1F2-4563-B684-AAF776E6B341}.Release|x86.ActiveCfg = Release|Any CPU + {91834652-D1F2-4563-B684-AAF776E6B341}.Release|x86.Build.0 = Release|Any CPU {A45980D7-8E11-4DF2-9802-D6E5E7056CD1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A45980D7-8E11-4DF2-9802-D6E5E7056CD1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A45980D7-8E11-4DF2-9802-D6E5E7056CD1}.Debug|x64.ActiveCfg = Debug|Any CPU + {A45980D7-8E11-4DF2-9802-D6E5E7056CD1}.Debug|x64.Build.0 = Debug|Any CPU + {A45980D7-8E11-4DF2-9802-D6E5E7056CD1}.Debug|x86.ActiveCfg = Debug|Any CPU + {A45980D7-8E11-4DF2-9802-D6E5E7056CD1}.Debug|x86.Build.0 = Debug|Any CPU {A45980D7-8E11-4DF2-9802-D6E5E7056CD1}.Release|Any CPU.ActiveCfg = Release|Any CPU {A45980D7-8E11-4DF2-9802-D6E5E7056CD1}.Release|Any CPU.Build.0 = Release|Any CPU + {A45980D7-8E11-4DF2-9802-D6E5E7056CD1}.Release|x64.ActiveCfg = Release|Any CPU + {A45980D7-8E11-4DF2-9802-D6E5E7056CD1}.Release|x64.Build.0 = Release|Any CPU + {A45980D7-8E11-4DF2-9802-D6E5E7056CD1}.Release|x86.ActiveCfg = Release|Any CPU + {A45980D7-8E11-4DF2-9802-D6E5E7056CD1}.Release|x86.Build.0 = Release|Any CPU {FF9E9629-FB10-4D56-B8B1-AA65748FECA7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {FF9E9629-FB10-4D56-B8B1-AA65748FECA7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FF9E9629-FB10-4D56-B8B1-AA65748FECA7}.Debug|x64.ActiveCfg = Debug|Any CPU + {FF9E9629-FB10-4D56-B8B1-AA65748FECA7}.Debug|x64.Build.0 = Debug|Any CPU + {FF9E9629-FB10-4D56-B8B1-AA65748FECA7}.Debug|x86.ActiveCfg = Debug|Any CPU + {FF9E9629-FB10-4D56-B8B1-AA65748FECA7}.Debug|x86.Build.0 = Debug|Any CPU {FF9E9629-FB10-4D56-B8B1-AA65748FECA7}.Release|Any CPU.ActiveCfg = Release|Any CPU {FF9E9629-FB10-4D56-B8B1-AA65748FECA7}.Release|Any CPU.Build.0 = Release|Any CPU + {FF9E9629-FB10-4D56-B8B1-AA65748FECA7}.Release|x64.ActiveCfg = Release|Any CPU + {FF9E9629-FB10-4D56-B8B1-AA65748FECA7}.Release|x64.Build.0 = Release|Any CPU + {FF9E9629-FB10-4D56-B8B1-AA65748FECA7}.Release|x86.ActiveCfg = Release|Any CPU + {FF9E9629-FB10-4D56-B8B1-AA65748FECA7}.Release|x86.Build.0 = Release|Any CPU {3181E206-1B81-4D8C-BFF2-9125A39D99F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3181E206-1B81-4D8C-BFF2-9125A39D99F5}.Debug|x64.ActiveCfg = Debug|Any CPU + {3181E206-1B81-4D8C-BFF2-9125A39D99F5}.Debug|x64.Build.0 = Debug|Any CPU + {3181E206-1B81-4D8C-BFF2-9125A39D99F5}.Debug|x86.ActiveCfg = Debug|Any CPU + {3181E206-1B81-4D8C-BFF2-9125A39D99F5}.Debug|x86.Build.0 = Debug|Any CPU {3181E206-1B81-4D8C-BFF2-9125A39D99F5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3181E206-1B81-4D8C-BFF2-9125A39D99F5}.Release|x64.ActiveCfg = Release|Any CPU + {3181E206-1B81-4D8C-BFF2-9125A39D99F5}.Release|x64.Build.0 = Release|Any CPU + {3181E206-1B81-4D8C-BFF2-9125A39D99F5}.Release|x86.ActiveCfg = Release|Any CPU + {3181E206-1B81-4D8C-BFF2-9125A39D99F5}.Release|x86.Build.0 = Release|Any CPU {A6E76585-17FA-4F7F-A8EE-C4C517C0ABEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A6E76585-17FA-4F7F-A8EE-C4C517C0ABEE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A6E76585-17FA-4F7F-A8EE-C4C517C0ABEE}.Debug|x64.ActiveCfg = Debug|Any CPU + {A6E76585-17FA-4F7F-A8EE-C4C517C0ABEE}.Debug|x64.Build.0 = Debug|Any CPU + {A6E76585-17FA-4F7F-A8EE-C4C517C0ABEE}.Debug|x86.ActiveCfg = Debug|Any CPU + {A6E76585-17FA-4F7F-A8EE-C4C517C0ABEE}.Debug|x86.Build.0 = Debug|Any CPU {A6E76585-17FA-4F7F-A8EE-C4C517C0ABEE}.Release|Any CPU.ActiveCfg = Release|Any CPU {A6E76585-17FA-4F7F-A8EE-C4C517C0ABEE}.Release|Any CPU.Build.0 = Release|Any CPU + {A6E76585-17FA-4F7F-A8EE-C4C517C0ABEE}.Release|x64.ActiveCfg = Release|Any CPU + {A6E76585-17FA-4F7F-A8EE-C4C517C0ABEE}.Release|x64.Build.0 = Release|Any CPU + {A6E76585-17FA-4F7F-A8EE-C4C517C0ABEE}.Release|x86.ActiveCfg = Release|Any CPU + {A6E76585-17FA-4F7F-A8EE-C4C517C0ABEE}.Release|x86.Build.0 = Release|Any CPU {822ECD72-5DB0-4637-B794-CE27B02827AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {822ECD72-5DB0-4637-B794-CE27B02827AC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {822ECD72-5DB0-4637-B794-CE27B02827AC}.Debug|x64.ActiveCfg = Debug|Any CPU + {822ECD72-5DB0-4637-B794-CE27B02827AC}.Debug|x64.Build.0 = Debug|Any CPU + {822ECD72-5DB0-4637-B794-CE27B02827AC}.Debug|x86.ActiveCfg = Debug|Any CPU + {822ECD72-5DB0-4637-B794-CE27B02827AC}.Debug|x86.Build.0 = Debug|Any CPU {822ECD72-5DB0-4637-B794-CE27B02827AC}.Release|Any CPU.ActiveCfg = Release|Any CPU {822ECD72-5DB0-4637-B794-CE27B02827AC}.Release|Any CPU.Build.0 = Release|Any CPU + {822ECD72-5DB0-4637-B794-CE27B02827AC}.Release|x64.ActiveCfg = Release|Any CPU + {822ECD72-5DB0-4637-B794-CE27B02827AC}.Release|x64.Build.0 = Release|Any CPU + {822ECD72-5DB0-4637-B794-CE27B02827AC}.Release|x86.ActiveCfg = Release|Any CPU + {822ECD72-5DB0-4637-B794-CE27B02827AC}.Release|x86.Build.0 = Release|Any CPU + {E3F3D28A-919C-4283-AEBD-A915A3EFE047}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E3F3D28A-919C-4283-AEBD-A915A3EFE047}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E3F3D28A-919C-4283-AEBD-A915A3EFE047}.Debug|x64.ActiveCfg = Debug|Any CPU + {E3F3D28A-919C-4283-AEBD-A915A3EFE047}.Debug|x64.Build.0 = Debug|Any CPU + {E3F3D28A-919C-4283-AEBD-A915A3EFE047}.Debug|x86.ActiveCfg = Debug|Any CPU + {E3F3D28A-919C-4283-AEBD-A915A3EFE047}.Debug|x86.Build.0 = Debug|Any CPU + {E3F3D28A-919C-4283-AEBD-A915A3EFE047}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E3F3D28A-919C-4283-AEBD-A915A3EFE047}.Release|Any CPU.Build.0 = Release|Any CPU + {E3F3D28A-919C-4283-AEBD-A915A3EFE047}.Release|x64.ActiveCfg = Release|Any CPU + {E3F3D28A-919C-4283-AEBD-A915A3EFE047}.Release|x64.Build.0 = Release|Any CPU + {E3F3D28A-919C-4283-AEBD-A915A3EFE047}.Release|x86.ActiveCfg = Release|Any CPU + {E3F3D28A-919C-4283-AEBD-A915A3EFE047}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution + {33773A69-F53A-4BC8-B3B9-564C8C9B0E23} = {7DBE5A9D-0500-4C35-A673-481C411B0FA1} + {C8E5E8C0-FAA5-49D3-B121-F5E5BBF7C0EF} = {7DBE5A9D-0500-4C35-A673-481C411B0FA1} + {13FED5D5-41CE-4DB5-A0FD-5A1EB5FBEF51} = {7DBE5A9D-0500-4C35-A673-481C411B0FA1} {B79F3B3E-C9CE-4629-ADE3-B1659AF9C673} = {13FED5D5-41CE-4DB5-A0FD-5A1EB5FBEF51} {5F02B2B8-FC8C-44D8-A0CD-E9E31115EAB5} = {B79F3B3E-C9CE-4629-ADE3-B1659AF9C673} - {15C3A03E-F2A5-4497-B0DE-A9EB73C9E6AF} = {0CBB6422-3CF8-4014-B881-E48DBC964D99} - {13FED5D5-41CE-4DB5-A0FD-5A1EB5FBEF51} = {7DBE5A9D-0500-4C35-A673-481C411B0FA1} - {33773A69-F53A-4BC8-B3B9-564C8C9B0E23} = {7DBE5A9D-0500-4C35-A673-481C411B0FA1} {1DB2232F-17CF-40AF-9190-09441C860BEB} = {7DBE5A9D-0500-4C35-A673-481C411B0FA1} - {C8E5E8C0-FAA5-49D3-B121-F5E5BBF7C0EF} = {7DBE5A9D-0500-4C35-A673-481C411B0FA1} + {15C3A03E-F2A5-4497-B0DE-A9EB73C9E6AF} = {0CBB6422-3CF8-4014-B881-E48DBC964D99} {91834652-D1F2-4563-B684-AAF776E6B341} = {5BD797BA-4D66-4F55-A55E-5F1063678D8B} {A45980D7-8E11-4DF2-9802-D6E5E7056CD1} = {5BD797BA-4D66-4F55-A55E-5F1063678D8B} {FF9E9629-FB10-4D56-B8B1-AA65748FECA7} = {7DBE5A9D-0500-4C35-A673-481C411B0FA1} @@ -148,6 +254,7 @@ Global {386F9478-8C54-4B48-A6C3-9F3D009A799C} = {73022F12-1D56-44FF-AFF1-9FEA43637836} {A6E76585-17FA-4F7F-A8EE-C4C517C0ABEE} = {B79F3B3E-C9CE-4629-ADE3-B1659AF9C673} {822ECD72-5DB0-4637-B794-CE27B02827AC} = {B79F3B3E-C9CE-4629-ADE3-B1659AF9C673} + {E3F3D28A-919C-4283-AEBD-A915A3EFE047} = {5BD797BA-4D66-4F55-A55E-5F1063678D8B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3E5E58EB-0096-4ED2-B1DE-D7FC5951CAB7} diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/CliBaseServiceCollectionExtensions.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/CliBaseServiceCollectionExtensions.cs index 861326c..eb8c797 100644 --- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/CliBaseServiceCollectionExtensions.cs +++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/CliBaseServiceCollectionExtensions.cs @@ -1,5 +1,7 @@ using CreativeCoders.HomeMatic.Core; using CreativeCoders.HomeMatic.Tools.Cli.Base.Commanding; +using CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Output; +using CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Serialization; using CreativeCoders.HomeMatic.Tools.Cli.Base.Connections; using CreativeCoders.HomeMatic.Tools.Cli.Base.SharedData; using Microsoft.Extensions.DependencyInjection; @@ -12,8 +14,6 @@ public static class CliBaseServiceCollectionExtensions public static void AddHomeMaticCliBase(this IServiceCollection services) { services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); services.TryAddSingleton(); @@ -21,6 +21,11 @@ public static void AddHomeMaticCliBase(this IServiceCollection services) services.TryAddSingleton(sp => sp.GetRequiredService().BuildMultiCcuClient()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.AddHomeMatic(); } } diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/CliBaseCommand.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/CliBaseCommand.cs deleted file mode 100644 index af703c9..0000000 --- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/CliBaseCommand.cs +++ /dev/null @@ -1,22 +0,0 @@ -using CreativeCoders.Core; -using CreativeCoders.HomeMatic.Tools.Cli.Base.SharedData; -using CreativeCoders.HomeMatic.XmlRpc; -using CreativeCoders.HomeMatic.XmlRpc.Client; - -namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Commanding; - -public abstract class CliBaseCommand(IHomeMaticXmlRpcApiBuilder apiBuilder, ISharedData sharedData) -{ - private readonly IHomeMaticXmlRpcApiBuilder _apiBuilder = Ensure.NotNull(apiBuilder); - - protected IHomeMaticXmlRpcApi BuildApi() - { - var cliData = SharedData.LoadCliData(); - - return _apiBuilder - .ForUrl(new Uri($"http://{cliData.CcuHost}:{CcuRpcPorts.HomeMaticIp}")) - .Build(); - } - - protected ISharedData SharedData { get; } = Ensure.NotNull(sharedData); -} diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/CliCommandBase.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/CliCommandBase.cs deleted file mode 100644 index 9f1d35f..0000000 --- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/CliCommandBase.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Commanding; - -public abstract class CliDataCommandBase : IHomeMaticCliCommandWithOptions - where TOptions : class -{ - public Task ExecuteAsync(TOptions options) - { - throw new NotImplementedException(); - } - - protected abstract Task LoadDataAsync(); -} diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/CliCommandExecutor.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/CliCommandExecutor.cs deleted file mode 100644 index 6c64d0c..0000000 --- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/CliCommandExecutor.cs +++ /dev/null @@ -1,35 +0,0 @@ -using CreativeCoders.Core; -using CreativeCoders.Core.Reflection; -using CreativeCoders.SysConsole.Cli.Actions; -using CreativeCoders.SysConsole.Cli.Actions.Exceptions; - -namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Commanding; - -public class CliCommandExecutor : ICliCommandExecutor -{ - private readonly IServiceProvider _serviceProvider; - - public CliCommandExecutor(IServiceProvider serviceProvider) - { - _serviceProvider = Ensure.NotNull(serviceProvider); - } - - public async Task ExecuteAsync(TOptions options) - where TCommand : IHomeMaticCliCommandWithOptions where TOptions : class - { - var command = typeof(TCommand).CreateInstance>(_serviceProvider); - - return command != null - ? new CliActionResult(await command.ExecuteAsync(options)) - : throw new CliActionException("Command object cannot be created"); - } - - public async Task ExecuteAsync() where TCommand : IHomeMaticCliCommand - { - var command = typeof(TCommand).CreateInstance(_serviceProvider); - - return command != null - ? new CliActionResult(await command.ExecuteAsync()) - : throw new CliActionException("Command object cannot be created"); - } -} \ No newline at end of file diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/ICliCommandExecutor.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/ICliCommandExecutor.cs deleted file mode 100644 index 193c697..0000000 --- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/ICliCommandExecutor.cs +++ /dev/null @@ -1,13 +0,0 @@ -using CreativeCoders.SysConsole.Cli.Actions; - -namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Commanding; - -public interface ICliCommandExecutor -{ - Task ExecuteAsync(TOptions options) - where TCommand : IHomeMaticCliCommandWithOptions - where TOptions : class; - - Task ExecuteAsync() - where TCommand : IHomeMaticCliCommand; -} \ No newline at end of file diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/IHomeMaticCliCommand.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/IHomeMaticCliCommand.cs deleted file mode 100644 index 391c1d8..0000000 --- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/IHomeMaticCliCommand.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Commanding; - -public interface IHomeMaticCliCommand -{ - Task ExecuteAsync(); -} diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/IHomeMaticCliCommandWithOptions.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/IHomeMaticCliCommandWithOptions.cs deleted file mode 100644 index 28338f8..0000000 --- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/IHomeMaticCliCommandWithOptions.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Commanding; - -public interface IHomeMaticCliCommandWithOptions - where TOptions : class -{ - Task ExecuteAsync(TOptions options); -} diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/DataOutputCommandBase.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/DataOutputCommandBase.cs new file mode 100644 index 0000000..3f5454d --- /dev/null +++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/DataOutputCommandBase.cs @@ -0,0 +1,127 @@ +using CreativeCoders.Cli.Core; +using CreativeCoders.Core; +using CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Output; +using CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Serialization; +using Spectre.Console; + +namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Commands; + +/// +/// Generic base class for CLI commands that load arbitrary data, serialize it as JSON or YAML +/// and write the result either to a file or to stdout. +/// +/// The type of data produced by . +/// The type of CLI options. Must implement . +public abstract class DataOutputCommandBase : ICliCommand + where TOptions : class, IDataOutputOptions +{ + private readonly IAnsiConsole _console; + + private readonly IDataSerializerFactory _serializerFactory; + + private readonly IDataOutputWriter _outputWriter; + + /// + /// Initializes a new instance of the class. + /// + /// The console used for status messages and stdout output. + /// The factory that resolves serializers for a format. + /// The writer that targets file or stdout. + protected DataOutputCommandBase( + IAnsiConsole console, + IDataSerializerFactory serializerFactory, + IDataOutputWriter outputWriter) + { + _console = Ensure.NotNull(console); + _serializerFactory = Ensure.NotNull(serializerFactory); + _outputWriter = Ensure.NotNull(outputWriter); + } + + /// + /// Gets the console available to subclasses for additional output. + /// + protected IAnsiConsole Console => _console; + + /// + public async Task ExecuteAsync(TOptions options) + { + Ensure.NotNull(options); + + var format = _outputWriter.ResolveFormat(options.OutputFormat, options.OutputFile); + + await OnBeforeLoadAsync(options).ConfigureAwait(false); + + var data = await LoadDataAsync(options).ConfigureAwait(false); + + var transformed = TransformData(data, options); + + Ensure.NotNull(transformed); + + var serializer = _serializerFactory.Create(format); + var content = serializer.Serialize(transformed); + + await OnBeforeWriteAsync(options, format).ConfigureAwait(false); + + await _outputWriter.WriteAsync(content, options.OutputFile).ConfigureAwait(false); + + await OnAfterWriteAsync(options, format).ConfigureAwait(false); + + return CommandResult.Success; + } + + /// + /// Loads the data to be serialized. + /// + /// The CLI options. + /// The loaded data. + protected abstract Task LoadDataAsync(TOptions options); + + /// + /// Transforms the loaded data into the object that is actually serialized. The default + /// implementation returns unchanged. + /// + /// The loaded data. + /// The CLI options. + /// The object passed to the serializer. + protected virtual object TransformData(TData data, TOptions options) => data!; + + /// + /// Hook invoked before data is loaded. The default implementation writes a status message + /// to the console. + /// + /// The CLI options. + /// A task that completes when the hook is finished. + protected virtual Task OnBeforeLoadAsync(TOptions options) + { + _console.WriteLine("Loading data..."); + + return Task.CompletedTask; + } + + /// + /// Hook invoked after serialization but before writing the result. The default implementation + /// writes a status message describing the resolved target. + /// + /// The CLI options. + /// The resolved output format. + /// A task that completes when the hook is finished. + protected virtual Task OnBeforeWriteAsync(TOptions options, DataOutputFormat resolvedFormat) + { + var target = string.IsNullOrWhiteSpace(options.OutputFile) + ? "stdout" + : $"file '{options.OutputFile}'"; + + _console.WriteLine($"Writing {resolvedFormat} output to {target}"); + + return Task.CompletedTask; + } + + /// + /// Hook invoked after the result has been written. The default implementation is a no-op. + /// + /// The CLI options. + /// The resolved output format. + /// A task that completes when the hook is finished. + protected virtual Task OnAfterWriteAsync(TOptions options, DataOutputFormat resolvedFormat) + => Task.CompletedTask; +} diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/DataOutputFormat.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/DataOutputFormat.cs new file mode 100644 index 0000000..6f9b685 --- /dev/null +++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/DataOutputFormat.cs @@ -0,0 +1,22 @@ +namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Commands; + +/// +/// Specifies the serialization format used to write CLI command output. +/// +public enum DataOutputFormat +{ + /// + /// Format is derived from the output file extension; falls back to . + /// + Auto = 0, + + /// + /// JSON output. + /// + Json = 1, + + /// + /// YAML output. + /// + Yaml = 2 +} diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/IDataOutputOptions.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/IDataOutputOptions.cs new file mode 100644 index 0000000..0484094 --- /dev/null +++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/IDataOutputOptions.cs @@ -0,0 +1,18 @@ +namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Commands; + +/// +/// Contract for CLI command options that control serialized data output. +/// +public interface IDataOutputOptions +{ + /// + /// Gets the desired output format. When set to + /// the format is derived from the output file extension. + /// + DataOutputFormat OutputFormat { get; } + + /// + /// Gets the path of the output file. When null or empty the data is written to stdout. + /// + string? OutputFile { get; } +} diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Output/DataOutputWriter.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Output/DataOutputWriter.cs new file mode 100644 index 0000000..70ede7f --- /dev/null +++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Output/DataOutputWriter.cs @@ -0,0 +1,75 @@ +using CreativeCoders.Core; +using CreativeCoders.Core.IO; +using Spectre.Console; + +namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Output; + +/// +/// Default implementation. Writes content either to a file via +/// or to the provided . +/// +public class DataOutputWriter : IDataOutputWriter +{ + private readonly IAnsiConsole _console; + + /// + /// Initializes a new instance of the class. + /// + /// The console used for stdout output. + public DataOutputWriter(IAnsiConsole console) + { + _console = Ensure.NotNull(console); + } + + /// + public DataOutputFormat ResolveFormat(DataOutputFormat requestedFormat, string? outputFile) + { + var fromExtension = TryGetFormatFromExtension(outputFile); + + if (fromExtension.HasValue) + { + return fromExtension.Value; + } + + return requestedFormat == DataOutputFormat.Auto + ? DataOutputFormat.Json + : requestedFormat; + } + + /// + public async Task WriteAsync(string content, string? outputFile) + { + Ensure.NotNull(content); + + if (string.IsNullOrWhiteSpace(outputFile)) + { + _console.WriteLine(content); + return; + } + + await FileSys.File.WriteAllTextAsync(outputFile, content).ConfigureAwait(false); + } + + private static DataOutputFormat? TryGetFormatFromExtension(string? outputFile) + { + if (string.IsNullOrWhiteSpace(outputFile)) + { + return null; + } + + var extension = Path.GetExtension(outputFile); + + if (string.IsNullOrEmpty(extension)) + { + return null; + } + + return extension.ToLowerInvariant() switch + { + ".json" => DataOutputFormat.Json, + ".yaml" => DataOutputFormat.Yaml, + ".yml" => DataOutputFormat.Yaml, + _ => null + }; + } +} diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Output/IDataOutputWriter.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Output/IDataOutputWriter.cs new file mode 100644 index 0000000..4e97bb0 --- /dev/null +++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Output/IDataOutputWriter.cs @@ -0,0 +1,24 @@ +namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Output; + +/// +/// Resolves the effective output format and writes serialized data either to a file or to stdout. +/// +public interface IDataOutputWriter +{ + /// + /// Determines the effective based on the requested format + /// and the output file extension. + /// + /// The format requested via options. + /// The path of the output file, or null/empty for stdout. + /// The resolved, concrete output format. + DataOutputFormat ResolveFormat(DataOutputFormat requestedFormat, string? outputFile); + + /// + /// Writes the serialized to the configured target. + /// + /// The already serialized content. + /// The target file path. When null or empty, the content is written to stdout. + /// A task that completes when the content has been written. + Task WriteAsync(string content, string? outputFile); +} diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Serialization/DataSerializerFactory.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Serialization/DataSerializerFactory.cs new file mode 100644 index 0000000..14a2c2f --- /dev/null +++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Serialization/DataSerializerFactory.cs @@ -0,0 +1,40 @@ +using CreativeCoders.Core; + +namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Serialization; + +/// +/// Default implementation backed by all registered +/// instances. +/// +public class DataSerializerFactory : IDataSerializerFactory +{ + private readonly Dictionary _serializers; + + /// + /// Initializes a new instance of the class. + /// + /// All registered serializers. + public DataSerializerFactory(IEnumerable serializers) + { + _serializers = Ensure.NotNull(serializers).ToDictionary(s => s.Format); + } + + /// + public IDataSerializer Create(DataOutputFormat format) + { + if (format == DataOutputFormat.Auto) + { + throw new ArgumentException( + "Format must be resolved to a concrete value before requesting a serializer.", + nameof(format)); + } + + if (!_serializers.TryGetValue(format, out var serializer)) + { + throw new InvalidOperationException( + $"No serializer registered for format '{format}'."); + } + + return serializer; + } +} diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Serialization/IDataSerializer.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Serialization/IDataSerializer.cs new file mode 100644 index 0000000..9819ff8 --- /dev/null +++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Serialization/IDataSerializer.cs @@ -0,0 +1,19 @@ +namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Serialization; + +/// +/// Serializes arbitrary data into a string representation for a specific output format. +/// +public interface IDataSerializer +{ + /// + /// Gets the format produced by this serializer. + /// + DataOutputFormat Format { get; } + + /// + /// Serializes the given to its string representation. + /// + /// The data to serialize. + /// The serialized representation of the data. + string Serialize(object data); +} diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Serialization/IDataSerializerFactory.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Serialization/IDataSerializerFactory.cs new file mode 100644 index 0000000..709afc6 --- /dev/null +++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Serialization/IDataSerializerFactory.cs @@ -0,0 +1,14 @@ +namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Serialization; + +/// +/// Creates instances for a requested . +/// +public interface IDataSerializerFactory +{ + /// + /// Returns the registered for the given . + /// + /// The desired output format. Must not be . + /// A serializer that produces output in . + IDataSerializer Create(DataOutputFormat format); +} diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Serialization/JsonDataSerializer.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Serialization/JsonDataSerializer.cs new file mode 100644 index 0000000..066cb5a --- /dev/null +++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Serialization/JsonDataSerializer.cs @@ -0,0 +1,30 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using CreativeCoders.Core; + +namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Serialization; + +/// +/// implementation that produces indented JSON using +/// System.Text.Json. +/// +public class JsonDataSerializer : IDataSerializer +{ + private static readonly JsonSerializerOptions Options = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + /// + public DataOutputFormat Format => DataOutputFormat.Json; + + /// + public string Serialize(object data) + { + Ensure.NotNull(data); + + return JsonSerializer.Serialize(data, Options); + } +} diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Serialization/YamlDataSerializer.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Serialization/YamlDataSerializer.cs new file mode 100644 index 0000000..b54c94c --- /dev/null +++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Serialization/YamlDataSerializer.cs @@ -0,0 +1,27 @@ +using CreativeCoders.Core; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Serialization; + +/// +/// implementation that produces YAML using YamlDotNet. +/// +public class YamlDataSerializer : IDataSerializer +{ + private static readonly ISerializer Serializer = new SerializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull) + .Build(); + + /// + public DataOutputFormat Format => DataOutputFormat.Yaml; + + /// + public string Serialize(object data) + { + Ensure.NotNull(data); + + return Serializer.Serialize(data); + } +} diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/CreativeCoders.HomeMatic.Tools.Cli.Base.csproj b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/CreativeCoders.HomeMatic.Tools.Cli.Base.csproj index 9ca760f..50a7d4e 100644 --- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/CreativeCoders.HomeMatic.Tools.Cli.Base.csproj +++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/CreativeCoders.HomeMatic.Tools.Cli.Base.csproj @@ -12,6 +12,7 @@ + diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Test/TestCommand.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Test/TestCommand.cs deleted file mode 100644 index 9085569..0000000 --- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Test/TestCommand.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System.Text.Json; -using CreativeCoders.Cli.Core; -using CreativeCoders.Core; -using CreativeCoders.HomeMatic.JsonRpc.Api; -using CreativeCoders.HomeMatic.Tools.Cli.Base.Commanding; -using CreativeCoders.HomeMatic.Tools.Cli.Base.SharedData; -using CreativeCoders.HomeMatic.XmlRpc.Client; -using Spectre.Console; - -namespace CreativeCoders.HomeMatic.Tools.Cli.Commands.Test; - -public class TestCommand( - IAnsiConsole console, - IHomeMaticXmlRpcApiBuilder apiBuilder, - ISharedData sharedData, - IHomeMaticJsonRpcApiBuilder jsonRpcApiBuilder) - : CliBaseCommand(apiBuilder, sharedData), ICliCommand -{ - private readonly IAnsiConsole _console = Ensure.NotNull(console); - - public async Task ExecuteAsync() - { - var cliData = SharedData.LoadCliData(); - if (!cliData.Users.TryGetValue(cliData.CcuHost, out var userName)) - { - userName = await _console.PromptAsync(new TextPrompt("User name: ")); - cliData.Users[cliData.CcuHost] = userName; - - SharedData.SaveCliData(cliData); - } - - var api = jsonRpcApiBuilder.ForUrl(new Uri($"http://{cliData.CcuHost}/api/homematic.cgi")).Build(); - - var loginResponse = await api.LoginAsync(userName, SharedData.GetPassword(cliData.CcuHost)); - - _console.WriteLine($"Login Response: {JsonSerializer.Serialize(loginResponse)}"); - - if (loginResponse.Result == null) - { - return 1; - } - - var listAllDetailsResponse = await api.ListAllDetailsAsync(loginResponse.Result); - - _console.WriteLine($"All Details Response: {JsonSerializer.Serialize(listAllDetailsResponse)}"); - - var logoutResponse = await api.LogoutAsync(loginResponse.Result); - - _console.WriteLine($"Logout Response: {JsonSerializer.Serialize(logoutResponse)}"); - - return CommandResult.Success; - } -} diff --git a/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/DataOutputCommandBaseAdditionalTests.cs b/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/DataOutputCommandBaseAdditionalTests.cs new file mode 100644 index 0000000..3438218 --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/DataOutputCommandBaseAdditionalTests.cs @@ -0,0 +1,103 @@ +using AwesomeAssertions; +using CreativeCoders.Cli.Core; +using CreativeCoders.HomeMatic.Tools.Cli.Base.Commands; +using CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Output; +using CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Serialization; +using FakeItEasy; +using Spectre.Console; + +namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Tests.Commands; + +public class DataOutputCommandBaseAdditionalTests +{ + private sealed class TestOptions : IDataOutputOptions + { + public DataOutputFormat OutputFormat { get; init; } = DataOutputFormat.Auto; + + public string? OutputFile { get; init; } + } + + private sealed class ConfigurableCommand : DataOutputCommandBase + { + public ConfigurableCommand( + IAnsiConsole console, + IDataSerializerFactory factory, + IDataOutputWriter writer) + : base(console, factory, writer) + { + } + + public Func>? LoadFunc { get; init; } + + public Func? TransformFunc { get; init; } + + protected override Task LoadDataAsync(TestOptions options) + => LoadFunc is null ? Task.FromResult("data") : LoadFunc(options); + + protected override object TransformData(string data, TestOptions options) + => TransformFunc is null ? data : TransformFunc(data, options)!; + } + + [Fact] + public async Task ExecuteAsync_WhenLoadDataThrows_ExceptionPropagates() + { + // Arrange + var sut = new ConfigurableCommand( + A.Fake(), + A.Fake(), + A.Fake()) + { + LoadFunc = _ => throw new InvalidOperationException("boom") + }; + + // Act + var act = async () => await sut.ExecuteAsync(new TestOptions()); + + // Assert + await act.Should().ThrowAsync().WithMessage("boom"); + } + + [Fact] + public async Task ExecuteAsync_WhenTransformReturnsNull_Throws() + { + // Arrange + var sut = new ConfigurableCommand( + A.Fake(), + A.Fake(), + A.Fake()) + { + TransformFunc = (_, _) => null + }; + + // Act + var act = async () => await sut.ExecuteAsync(new TestOptions()); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ExecuteAsync_WhenWriterThrows_ExceptionPropagates() + { + // Arrange + var serializer = A.Fake(); + A.CallTo(() => serializer.Serialize(A._)).Returns("payload"); + + var factory = A.Fake(); + A.CallTo(() => factory.Create(A._)).Returns(serializer); + + var writer = A.Fake(); + A.CallTo(() => writer.ResolveFormat(A._, A._)) + .Returns(DataOutputFormat.Json); + A.CallTo(() => writer.WriteAsync(A._, A._)) + .ThrowsAsync(new IOException("write failed")); + + var sut = new ConfigurableCommand(A.Fake(), factory, writer); + + // Act + var act = async () => await sut.ExecuteAsync(new TestOptions()); + + // Assert + await act.Should().ThrowAsync().WithMessage("write failed"); + } +} diff --git a/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/DataOutputCommandBaseTests.cs b/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/DataOutputCommandBaseTests.cs new file mode 100644 index 0000000..3e37fd0 --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/DataOutputCommandBaseTests.cs @@ -0,0 +1,196 @@ +using AwesomeAssertions; +using CreativeCoders.Cli.Core; +using CreativeCoders.HomeMatic.Tools.Cli.Base.Commands; +using CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Output; +using CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Serialization; +using FakeItEasy; +using Spectre.Console; + +namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Tests.Commands; + +public class DataOutputCommandBaseTests +{ + private sealed class TestOptions : IDataOutputOptions + { + public DataOutputFormat OutputFormat { get; init; } = DataOutputFormat.Auto; + + public string? OutputFile { get; init; } + } + + private sealed class TestCommand : DataOutputCommandBase + { + private readonly string _data; + + public TestCommand( + IAnsiConsole console, + IDataSerializerFactory factory, + IDataOutputWriter writer, + string data) + : base(console, factory, writer) + { + _data = data; + } + + public int LoadCount { get; private set; } + + public int TransformCount { get; private set; } + + public int BeforeLoadCount { get; private set; } + + public int BeforeWriteCount { get; private set; } + + public int AfterWriteCount { get; private set; } + + public DataOutputFormat? LastResolvedFormat { get; private set; } + + protected override Task LoadDataAsync(TestOptions options) + { + LoadCount++; + + return Task.FromResult(_data); + } + + protected override object TransformData(string data, TestOptions options) + { + TransformCount++; + + return new { Wrapped = data }; + } + + protected override Task OnBeforeLoadAsync(TestOptions options) + { + BeforeLoadCount++; + + return Task.CompletedTask; + } + + protected override Task OnBeforeWriteAsync(TestOptions options, DataOutputFormat resolvedFormat) + { + BeforeWriteCount++; + LastResolvedFormat = resolvedFormat; + + return Task.CompletedTask; + } + + protected override Task OnAfterWriteAsync(TestOptions options, DataOutputFormat resolvedFormat) + { + AfterWriteCount++; + + return Task.CompletedTask; + } + } + + private static (TestCommand Sut, IDataSerializer Serializer, IDataSerializerFactory Factory, + IDataOutputWriter Writer) CreateSut( + string serialized, + DataOutputFormat resolvedFormat, + string data = "payload") + { + var console = A.Fake(); + var serializer = A.Fake(); + A.CallTo(() => serializer.Serialize(A._)).Returns(serialized); + + var factory = A.Fake(); + A.CallTo(() => factory.Create(A._)).Returns(serializer); + + var writer = A.Fake(); + A.CallTo(() => writer.ResolveFormat(A._, A._)) + .Returns(resolvedFormat); + + var sut = new TestCommand(console, factory, writer, data); + + return (sut, serializer, factory, writer); + } + + [Fact] + public async Task ExecuteAsync_OrchestratesLoadTransformSerializeAndWrite() + { + // Arrange + var (sut, serializer, factory, writer) = CreateSut("serialized", DataOutputFormat.Yaml); + var options = new TestOptions { OutputFormat = DataOutputFormat.Auto, OutputFile = "out.yaml" }; + + // Act + var result = await sut.ExecuteAsync(options); + + // Assert + result.Should().Be(CommandResult.Success); + sut.LoadCount.Should().Be(1); + sut.TransformCount.Should().Be(1); + A.CallTo(() => writer.ResolveFormat(DataOutputFormat.Auto, "out.yaml")).MustHaveHappenedOnceExactly(); + A.CallTo(() => factory.Create(DataOutputFormat.Yaml)).MustHaveHappenedOnceExactly(); + A.CallTo(() => serializer.Serialize(A._)).MustHaveHappenedOnceExactly(); + A.CallTo(() => writer.WriteAsync("serialized", "out.yaml")).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task ExecuteAsync_InvokesAllHooks() + { + // Arrange + var (sut, _, _, _) = CreateSut("serialized", DataOutputFormat.Json); + var options = new TestOptions(); + + // Act + await sut.ExecuteAsync(options); + + // Assert + sut.BeforeLoadCount.Should().Be(1); + sut.BeforeWriteCount.Should().Be(1); + sut.AfterWriteCount.Should().Be(1); + sut.LastResolvedFormat.Should().Be(DataOutputFormat.Json); + } + + [Fact] + public async Task ExecuteAsync_WithNullOptions_Throws() + { + // Arrange + var (sut, _, _, _) = CreateSut("serialized", DataOutputFormat.Json); + + // Act + var act = async () => await sut.ExecuteAsync(null!); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public void Constructor_WithNullConsole_Throws() + { + // Arrange + var factory = A.Fake(); + var writer = A.Fake(); + + // Act + var act = () => new TestCommand(null!, factory, writer, "x"); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Constructor_WithNullFactory_Throws() + { + // Arrange + var console = A.Fake(); + var writer = A.Fake(); + + // Act + var act = () => new TestCommand(console, null!, writer, "x"); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Constructor_WithNullWriter_Throws() + { + // Arrange + var console = A.Fake(); + var factory = A.Fake(); + + // Act + var act = () => new TestCommand(console, factory, null!, "x"); + + // Assert + act.Should().Throw(); + } +} diff --git a/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/Output/DataOutputWriterTests.cs b/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/Output/DataOutputWriterTests.cs new file mode 100644 index 0000000..6c4c2df --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/Output/DataOutputWriterTests.cs @@ -0,0 +1,194 @@ +using AwesomeAssertions; +using CreativeCoders.HomeMatic.Tools.Cli.Base.Commands; +using CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Output; +using FakeItEasy; +using Spectre.Console; + +namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Tests.Commands.Output; + +public class DataOutputWriterTests +{ + [Theory] + [InlineData("output.json", DataOutputFormat.Json)] + [InlineData("Output.JSON", DataOutputFormat.Json)] + [InlineData("/tmp/data.yaml", DataOutputFormat.Yaml)] + [InlineData("data.YML", DataOutputFormat.Yaml)] + public void ResolveFormat_WhenFileExtensionKnown_ReturnsFormatFromExtension( + string outputFile, DataOutputFormat expected) + { + // Arrange + var sut = new DataOutputWriter(A.Fake()); + + // Act + var result = sut.ResolveFormat(DataOutputFormat.Json, outputFile); + + // Assert + result.Should().Be(expected); + } + + [Fact] + public void ResolveFormat_WhenFileExtensionKnown_OverridesRequestedFormat() + { + // Arrange + var sut = new DataOutputWriter(A.Fake()); + + // Act + var result = sut.ResolveFormat(DataOutputFormat.Json, "result.yaml"); + + // Assert + result.Should().Be(DataOutputFormat.Yaml); + } + + [Theory] + [InlineData(DataOutputFormat.Json, DataOutputFormat.Json)] + [InlineData(DataOutputFormat.Yaml, DataOutputFormat.Yaml)] + [InlineData(DataOutputFormat.Auto, DataOutputFormat.Json)] + public void ResolveFormat_WhenNoOutputFile_UsesRequestedOrDefault( + DataOutputFormat requested, DataOutputFormat expected) + { + // Arrange + var sut = new DataOutputWriter(A.Fake()); + + // Act + var result = sut.ResolveFormat(requested, null); + + // Assert + result.Should().Be(expected); + } + + [Fact] + public void ResolveFormat_WhenExtensionUnknown_FallsBackToRequestedFormat() + { + // Arrange + var sut = new DataOutputWriter(A.Fake()); + + // Act + var result = sut.ResolveFormat(DataOutputFormat.Yaml, "data.txt"); + + // Assert + result.Should().Be(DataOutputFormat.Yaml); + } + + [Fact] + public void ResolveFormat_WhenExtensionUnknownAndAuto_DefaultsToJson() + { + // Arrange + var sut = new DataOutputWriter(A.Fake()); + + // Act + var result = sut.ResolveFormat(DataOutputFormat.Auto, "data.txt"); + + // Assert + result.Should().Be(DataOutputFormat.Json); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task WriteAsync_WithoutOutputFile_WritesToConsole(string? outputFile) + { + // Arrange + var console = A.Fake(); + var sut = new DataOutputWriter(console); + + // Act + await sut.WriteAsync("payload", outputFile); + + // Assert + A.CallTo(console) + .Where(c => c.Method.Name == nameof(IAnsiConsole.Write)) + .MustHaveHappened(); + } + + [Fact] + public async Task WriteAsync_WithOutputFile_WritesContentToFile() + { + // Arrange + var sut = new DataOutputWriter(A.Fake()); + var path = Path.Combine(Path.GetTempPath(), $"data-output-writer-{Guid.NewGuid():N}.json"); + + try + { + // Act + await sut.WriteAsync("hello", path); + + // Assert + File.Exists(path).Should().BeTrue(); + (await File.ReadAllTextAsync(path)).Should().Be("hello"); + } + finally + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + } + + [Fact] + public async Task WriteAsync_WithNullContent_Throws() + { + // Arrange + var sut = new DataOutputWriter(A.Fake()); + + // Act + var act = async () => await sut.WriteAsync(null!, null); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public void ResolveFormat_WithEmptyOutputFile_BehavesLikeNull() + { + // Arrange + var sut = new DataOutputWriter(A.Fake()); + + // Act + var result = sut.ResolveFormat(DataOutputFormat.Yaml, string.Empty); + + // Assert + result.Should().Be(DataOutputFormat.Yaml); + } + + [Fact] + public void ResolveFormat_WithMultipleDotsInFileName_UsesLastExtension() + { + // Arrange + var sut = new DataOutputWriter(A.Fake()); + + // Act + var result = sut.ResolveFormat(DataOutputFormat.Yaml, "data.backup.json"); + + // Assert + result.Should().Be(DataOutputFormat.Json); + } + + [Fact] + public async Task WriteAsync_ToNonExistentDirectory_Throws() + { + // Arrange + var sut = new DataOutputWriter(A.Fake()); + var path = Path.Combine( + Path.GetTempPath(), + $"missing-dir-{Guid.NewGuid():N}", + "out.json"); + + // Act + var act = async () => await sut.WriteAsync("data", path); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public void Constructor_WithNullConsole_Throws() + { + // Arrange & Act + var act = () => new DataOutputWriter(null!); + + // Assert + act.Should().Throw(); + } +} diff --git a/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/Serialization/DataSerializerFactoryTests.cs b/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/Serialization/DataSerializerFactoryTests.cs new file mode 100644 index 0000000..a3f800a --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/Serialization/DataSerializerFactoryTests.cs @@ -0,0 +1,96 @@ +using AwesomeAssertions; +using CreativeCoders.HomeMatic.Tools.Cli.Base.Commands; +using CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Serialization; +using FakeItEasy; + +namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Tests.Commands.Serialization; + +public class DataSerializerFactoryTests +{ + private static IDataSerializer FakeSerializer(DataOutputFormat format) + { + var serializer = A.Fake(); + A.CallTo(() => serializer.Format).Returns(format); + + return serializer; + } + + [Fact] + public void Create_WithJsonFormat_ReturnsRegisteredJsonSerializer() + { + // Arrange + var jsonSerializer = FakeSerializer(DataOutputFormat.Json); + var yamlSerializer = FakeSerializer(DataOutputFormat.Yaml); + var sut = new DataSerializerFactory(new[] { jsonSerializer, yamlSerializer }); + + // Act + var result = sut.Create(DataOutputFormat.Json); + + // Assert + result.Should().BeSameAs(jsonSerializer); + } + + [Fact] + public void Create_WithYamlFormat_ReturnsRegisteredYamlSerializer() + { + // Arrange + var jsonSerializer = FakeSerializer(DataOutputFormat.Json); + var yamlSerializer = FakeSerializer(DataOutputFormat.Yaml); + var sut = new DataSerializerFactory(new[] { jsonSerializer, yamlSerializer }); + + // Act + var result = sut.Create(DataOutputFormat.Yaml); + + // Assert + result.Should().BeSameAs(yamlSerializer); + } + + [Fact] + public void Create_WithAuto_Throws() + { + // Arrange + var sut = new DataSerializerFactory(new[] { FakeSerializer(DataOutputFormat.Json) }); + + // Act + var act = () => sut.Create(DataOutputFormat.Auto); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Create_WhenFormatNotRegistered_Throws() + { + // Arrange + var sut = new DataSerializerFactory(new[] { FakeSerializer(DataOutputFormat.Json) }); + + // Act + var act = () => sut.Create(DataOutputFormat.Yaml); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Create_WithEmptySerializers_ThrowsForAnyFormat() + { + // Arrange + var sut = new DataSerializerFactory(Array.Empty()); + + // Act + var act = () => sut.Create(DataOutputFormat.Json); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Constructor_WithNullSerializers_Throws() + { + // Arrange & Act + var act = () => new DataSerializerFactory(null!); + + // Assert + act.Should().Throw(); + } +} diff --git a/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/Serialization/JsonDataSerializerTests.cs b/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/Serialization/JsonDataSerializerTests.cs new file mode 100644 index 0000000..7bf4b19 --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/Serialization/JsonDataSerializerTests.cs @@ -0,0 +1,105 @@ +using AwesomeAssertions; +using CreativeCoders.HomeMatic.Tools.Cli.Base.Commands; +using CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Serialization; + +namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Tests.Commands.Serialization; + +public class JsonDataSerializerTests +{ + private sealed class SampleData + { + public string FirstName { get; set; } = string.Empty; + + public int? Age { get; set; } + + public string? OptionalNote { get; set; } + } + + [Fact] + public void Format_Returns_Json() + { + // Arrange + var sut = new JsonDataSerializer(); + + // Act + var format = sut.Format; + + // Assert + format.Should().Be(DataOutputFormat.Json); + } + + [Fact] + public void Serialize_WithObject_ProducesIndentedCamelCaseJson() + { + // Arrange + var sut = new JsonDataSerializer(); + var data = new SampleData { FirstName = "Alice", Age = 42 }; + + // Act + var result = sut.Serialize(data); + + // Assert + result.Should().Contain("\"firstName\": \"Alice\""); + result.Should().Contain("\"age\": 42"); + result.Should().Contain("\n"); + } + + [Fact] + public void Serialize_WithNullProperty_OmitsTheProperty() + { + // Arrange + var sut = new JsonDataSerializer(); + var data = new SampleData { FirstName = "Bob", Age = null, OptionalNote = null }; + + // Act + var result = sut.Serialize(data); + + // Assert + result.Should().NotContain("optionalNote"); + result.Should().NotContain("age"); + } + + [Fact] + public void Serialize_WithCollection_IncludesAllItems() + { + // Arrange + var sut = new JsonDataSerializer(); + + // Act + var result = sut.Serialize(new[] { "a", "b", "c" }); + + // Assert + result.Should().Contain("\"a\""); + result.Should().Contain("\"b\""); + result.Should().Contain("\"c\""); + } + + [Fact] + public void Serialize_WithNestedObjectContainingNullProperty_OmitsNullProperty() + { + // Arrange + var sut = new JsonDataSerializer(); + var data = new { Outer = new SampleData { FirstName = "X", Age = null, OptionalNote = null } }; + + // Act + var result = sut.Serialize(data); + + // Assert + result.Should().Contain("\"firstName\": \"X\""); + result.Should().NotContain("optionalNote"); + result.Should().NotContain("\"age\""); + } + + [Fact] + public void Serialize_WithNullData_Throws() + { + // Arrange + var sut = new JsonDataSerializer(); + + // Act + var act = () => sut.Serialize(null!); + + // Assert + act.Should().Throw(); + } +} diff --git a/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/Serialization/YamlDataSerializerTests.cs b/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/Serialization/YamlDataSerializerTests.cs new file mode 100644 index 0000000..edc1f62 --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/Serialization/YamlDataSerializerTests.cs @@ -0,0 +1,104 @@ +using AwesomeAssertions; +using CreativeCoders.HomeMatic.Tools.Cli.Base.Commands; +using CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Serialization; + +namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Tests.Commands.Serialization; + +public class YamlDataSerializerTests +{ + private sealed class SampleData + { + public string FirstName { get; set; } = string.Empty; + + public int? Age { get; set; } + + public string? OptionalNote { get; set; } + } + + [Fact] + public void Format_Returns_Yaml() + { + // Arrange + var sut = new YamlDataSerializer(); + + // Act + var format = sut.Format; + + // Assert + format.Should().Be(DataOutputFormat.Yaml); + } + + [Fact] + public void Serialize_WithObject_ProducesCamelCaseYaml() + { + // Arrange + var sut = new YamlDataSerializer(); + var data = new SampleData { FirstName = "Alice", Age = 42 }; + + // Act + var result = sut.Serialize(data); + + // Assert + result.Should().Contain("firstName: Alice"); + result.Should().Contain("age: 42"); + } + + [Fact] + public void Serialize_WithNullProperty_OmitsTheProperty() + { + // Arrange + var sut = new YamlDataSerializer(); + var data = new SampleData { FirstName = "Bob", Age = null, OptionalNote = null }; + + // Act + var result = sut.Serialize(data); + + // Assert + result.Should().NotContain("optionalNote"); + result.Should().NotContain("age"); + } + + [Fact] + public void Serialize_WithCollection_IncludesAllItems() + { + // Arrange + var sut = new YamlDataSerializer(); + + // Act + var result = sut.Serialize(new[] { "a", "b", "c" }); + + // Assert + result.Should().Contain("- a"); + result.Should().Contain("- b"); + result.Should().Contain("- c"); + } + + [Fact] + public void Serialize_WithNestedObjectContainingNullProperty_OmitsNullProperty() + { + // Arrange + var sut = new YamlDataSerializer(); + var data = new { Outer = new SampleData { FirstName = "X", Age = null, OptionalNote = null } }; + + // Act + var result = sut.Serialize(data); + + // Assert + result.Should().Contain("firstName: X"); + result.Should().NotContain("optionalNote"); + result.Should().NotContain("age:"); + } + + [Fact] + public void Serialize_WithNullData_Throws() + { + // Arrange + var sut = new YamlDataSerializer(); + + // Act + var act = () => sut.Serialize(null!); + + // Assert + act.Should().Throw(); + } +} diff --git a/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests.csproj b/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests.csproj new file mode 100644 index 0000000..a627346 --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests.csproj @@ -0,0 +1,36 @@ + + + + enable + enable + + false + true + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + From 72fd5a62f82655ea1fe85b9cbd8b2481154f6287 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 26 Apr 2026 19:53:11 +0200 Subject: [PATCH 08/16] refactor(cli): replace legacy collection initializations and improve property handling - Updated collection initializations to use concise syntax for readability. - Marked `DataOutputCommandBase` class with `[PublicAPI]` for better tooling support. - Removed redundant private field `_console` and replaced it with a direct property `Console`. - Consolidated YAML format handling in `DataOutputWriter`. --- .../Commands/DataOutputCommandBase.cs | 12 ++++++------ .../Commands/Output/DataOutputWriter.cs | 3 +-- .../Commands/DataOutputCommandBaseAdditionalTests.cs | 1 - .../Serialization/DataSerializerFactoryTests.cs | 10 +++++----- 4 files changed, 12 insertions(+), 14 deletions(-) diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/DataOutputCommandBase.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/DataOutputCommandBase.cs index 3f5454d..40b78bd 100644 --- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/DataOutputCommandBase.cs +++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/DataOutputCommandBase.cs @@ -2,6 +2,7 @@ using CreativeCoders.Core; using CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Output; using CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Serialization; +using JetBrains.Annotations; using Spectre.Console; namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Commands; @@ -12,11 +13,10 @@ namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Commands; /// /// The type of data produced by . /// The type of CLI options. Must implement . +[PublicAPI] public abstract class DataOutputCommandBase : ICliCommand where TOptions : class, IDataOutputOptions { - private readonly IAnsiConsole _console; - private readonly IDataSerializerFactory _serializerFactory; private readonly IDataOutputWriter _outputWriter; @@ -32,7 +32,7 @@ protected DataOutputCommandBase( IDataSerializerFactory serializerFactory, IDataOutputWriter outputWriter) { - _console = Ensure.NotNull(console); + Console = Ensure.NotNull(console); _serializerFactory = Ensure.NotNull(serializerFactory); _outputWriter = Ensure.NotNull(outputWriter); } @@ -40,7 +40,7 @@ protected DataOutputCommandBase( /// /// Gets the console available to subclasses for additional output. /// - protected IAnsiConsole Console => _console; + protected IAnsiConsole Console { get; } /// public async Task ExecuteAsync(TOptions options) @@ -93,7 +93,7 @@ public async Task ExecuteAsync(TOptions options) /// A task that completes when the hook is finished. protected virtual Task OnBeforeLoadAsync(TOptions options) { - _console.WriteLine("Loading data..."); + Console.WriteLine("Loading data..."); return Task.CompletedTask; } @@ -111,7 +111,7 @@ protected virtual Task OnBeforeWriteAsync(TOptions options, DataOutputFormat res ? "stdout" : $"file '{options.OutputFile}'"; - _console.WriteLine($"Writing {resolvedFormat} output to {target}"); + Console.WriteLine($"Writing {resolvedFormat} output to {target}"); return Task.CompletedTask; } diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Output/DataOutputWriter.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Output/DataOutputWriter.cs index 70ede7f..dfd88b5 100644 --- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Output/DataOutputWriter.cs +++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Output/DataOutputWriter.cs @@ -67,8 +67,7 @@ public async Task WriteAsync(string content, string? outputFile) return extension.ToLowerInvariant() switch { ".json" => DataOutputFormat.Json, - ".yaml" => DataOutputFormat.Yaml, - ".yml" => DataOutputFormat.Yaml, + ".yaml" or ".yml" => DataOutputFormat.Yaml, _ => null }; } diff --git a/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/DataOutputCommandBaseAdditionalTests.cs b/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/DataOutputCommandBaseAdditionalTests.cs index 3438218..4229c07 100644 --- a/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/DataOutputCommandBaseAdditionalTests.cs +++ b/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/DataOutputCommandBaseAdditionalTests.cs @@ -1,5 +1,4 @@ using AwesomeAssertions; -using CreativeCoders.Cli.Core; using CreativeCoders.HomeMatic.Tools.Cli.Base.Commands; using CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Output; using CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Serialization; diff --git a/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/Serialization/DataSerializerFactoryTests.cs b/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/Serialization/DataSerializerFactoryTests.cs index a3f800a..452da94 100644 --- a/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/Serialization/DataSerializerFactoryTests.cs +++ b/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/Serialization/DataSerializerFactoryTests.cs @@ -21,7 +21,7 @@ public void Create_WithJsonFormat_ReturnsRegisteredJsonSerializer() // Arrange var jsonSerializer = FakeSerializer(DataOutputFormat.Json); var yamlSerializer = FakeSerializer(DataOutputFormat.Yaml); - var sut = new DataSerializerFactory(new[] { jsonSerializer, yamlSerializer }); + var sut = new DataSerializerFactory([jsonSerializer, yamlSerializer]); // Act var result = sut.Create(DataOutputFormat.Json); @@ -36,7 +36,7 @@ public void Create_WithYamlFormat_ReturnsRegisteredYamlSerializer() // Arrange var jsonSerializer = FakeSerializer(DataOutputFormat.Json); var yamlSerializer = FakeSerializer(DataOutputFormat.Yaml); - var sut = new DataSerializerFactory(new[] { jsonSerializer, yamlSerializer }); + var sut = new DataSerializerFactory([jsonSerializer, yamlSerializer]); // Act var result = sut.Create(DataOutputFormat.Yaml); @@ -49,7 +49,7 @@ public void Create_WithYamlFormat_ReturnsRegisteredYamlSerializer() public void Create_WithAuto_Throws() { // Arrange - var sut = new DataSerializerFactory(new[] { FakeSerializer(DataOutputFormat.Json) }); + var sut = new DataSerializerFactory([FakeSerializer(DataOutputFormat.Json)]); // Act var act = () => sut.Create(DataOutputFormat.Auto); @@ -62,7 +62,7 @@ public void Create_WithAuto_Throws() public void Create_WhenFormatNotRegistered_Throws() { // Arrange - var sut = new DataSerializerFactory(new[] { FakeSerializer(DataOutputFormat.Json) }); + var sut = new DataSerializerFactory([FakeSerializer(DataOutputFormat.Json)]); // Act var act = () => sut.Create(DataOutputFormat.Yaml); @@ -75,7 +75,7 @@ public void Create_WhenFormatNotRegistered_Throws() public void Create_WithEmptySerializers_ThrowsForAnyFormat() { // Arrange - var sut = new DataSerializerFactory(Array.Empty()); + var sut = new DataSerializerFactory([]); // Act var act = () => sut.Create(DataOutputFormat.Json); From c8a934874136b31a87da96bc723a967b9f3de22f Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:24:50 +0200 Subject: [PATCH 09/16] feat(project): add initial Serena configuration for "HomeMatic" - Introduced `.serena/project.yml` with project-specific settings. - Configured C# as the primary language for the Serena language server. - Enabled gitignore integration and UTF-8 encoding as defaults. - Added placeholders for advanced project and tool configurations. --- .serena/.gitignore | 2 + .serena/project.yml | 154 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 .serena/.gitignore create mode 100644 .serena/project.yml diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000..2e510af --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1,2 @@ +/cache +/project.local.yml diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..213ff16 --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,154 @@ +# the name by which the project can be referenced within Serena +project_name: "HomeMatic" + + +# list of languages for which language servers are started; choose from: +# al bash clojure cpp csharp +# csharp_omnisharp dart elixir elm erlang +# fortran fsharp go groovy haskell +# haxe java julia kotlin lua +# markdown +# matlab nix pascal perl php +# php_phpactor powershell python python_jedi r +# rego ruby ruby_solargraph rust scala +# swift terraform toml typescript typescript_vts +# vue yaml zig +# (This list may be outdated. For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some languages require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: +- csharp + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: "utf-8" + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + +# whether to use project's .gitignore files to ignore files +ignore_all_files_in_gitignore: true + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. +# No documentation on options means no options are available. +ls_specific_settings: {} + +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. +# This extends the existing exclusions (e.g. from the global configuration) +# +# Below is the complete list of tools for convenience. +# To make sure you have the latest list of tools, and to view their descriptions, +# execute `uv run scripts/print_tool_overview.py`. +# +# * `activate_project`: Activates a project based on the project name or path. +# * `check_onboarding_performed`: Checks whether project onboarding was already performed. +# * `create_text_file`: Creates/overwrites a file in the project directory. +# * `delete_memory`: Delete a memory file. Should only happen if a user asks for it explicitly, +# for example by saying that the information retrieved from a memory file is no longer correct +# or no longer relevant for the project. +# * `edit_memory`: Replaces content matching a regular expression in a memory. +# * `execute_shell_command`: Executes a shell command. +# * `find_file`: Finds files in the given relative paths +# * `find_referencing_symbols`: Finds symbols that reference the given symbol using the language server backend +# * `find_symbol`: Performs a global (or local) search using the language server backend. +# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. +# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. +# * `initial_instructions`: Provides instructions Serena usage (i.e. the 'Serena Instructions Manual') +# for clients that do not read the initial instructions when the MCP server is connected. +# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. +# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. +# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). +# * `list_memories`: List available memories. Any memory can be read using the `read_memory` tool. +# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). +# * `read_file`: Reads a file within the project directory. +# * `read_memory`: Read the content of a memory file. This tool should only be used if the information +# is relevant to the current task. You can infer whether the information +# is relevant from the memory file name. +# You should not read the same memory file multiple times in the same conversation. +# * `rename_memory`: Renames or moves a memory. Moving between project and global scope is supported +# (e.g., renaming "global/foo" to "bar" moves it from global to project scope). +# * `rename_symbol`: Renames a symbol throughout the codebase using language server refactoring capabilities. +# For JB, we use a separate tool. +# * `replace_content`: Replaces content in a file (optionally using regular expressions). +# * `replace_symbol_body`: Replaces the full definition of a symbol using the language server backend. +# * `safe_delete_symbol`: +# * `search_for_pattern`: Performs a search for a pattern in the project. +# * `write_memory`: Write some information (utf-8-encoded) about this project that can be useful for future tasks to a memory in md format. +# The memory name should be meaningful. +excluded_tools: [] + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +fixed_tools: [] + +# list of mode names to that are always to be included in the set of active modes +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this setting overrides the global configuration. +# Set this to [] to disable base modes for this project. +# Set this to a list of mode names to always include the respective modes for this project. +base_modes: + +# list of mode names that are to be activated by default. +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# This setting can, in turn, be overridden by CLI parameters (--mode). +default_modes: + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. +symbol_info_budget: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] From 4b1df468998b624c799e670f0c0ff28a9ab42962 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:53:38 +0200 Subject: [PATCH 10/16] feat(xmlrpc): add `Links` module with support for link management and unit tests - Introduced `Link` and `LinkInfo` classes to represent HomeMatic communication links. - Added `LinkFlags` and `GetLinksFlags` enums for link state and API filtering options. - Implemented extension methods in `HomeMaticXmlRpcApiLinkExtensions` for strongly-typed link operations. - Updated `IHomeMaticXmlRpcApi` with new methods: `GetLinksAsync`, `AddLinkAsync`, `RemoveLinkAsync`, `SetLinkInfoAsync`, and `ActivateLinkParamsetAsync`. - Added `CreativeCoders.HomeMatic.XmlRpc.Tests` project with comprehensive tests for the `Links` module, ensuring full coverage. --- HomeMatic.sln | 15 +++ .../HomeMaticXmlRpcApiLinkExtensions.cs | 65 ++++++++++++ .../Client/IHomeMaticXmlRpcApi.cs | 98 +++++++++++++++++++ .../Links/GetLinksFlags.cs | 39 ++++++++ .../Links/Link.cs | 78 +++++++++++++++ .../Links/LinkFlags.cs | 32 ++++++ .../Links/LinkInfo.cs | 27 +++++ ...meMaticXmlRpcApiBuilderLinkSurfaceTests.cs | 40 ++++++++ .../HomeMaticXmlRpcApiLinkExtensionsTests.cs | 75 ++++++++++++++ ...FlagsMemberValueConverterLinkFlagsTests.cs | 33 +++++++ ...eativeCoders.HomeMatic.XmlRpc.Tests.csproj | 35 +++++++ .../Links/GetLinksFlagsTests.cs | 28 ++++++ .../Links/LinkFlagsTests.cs | 35 +++++++ .../Links/LinkTests.cs | 33 +++++++ 14 files changed, 633 insertions(+) create mode 100644 source/CreativeCoders.HomeMatic.XmlRpc/Client/HomeMaticXmlRpcApiLinkExtensions.cs create mode 100644 source/CreativeCoders.HomeMatic.XmlRpc/Links/GetLinksFlags.cs create mode 100644 source/CreativeCoders.HomeMatic.XmlRpc/Links/Link.cs create mode 100644 source/CreativeCoders.HomeMatic.XmlRpc/Links/LinkFlags.cs create mode 100644 source/CreativeCoders.HomeMatic.XmlRpc/Links/LinkInfo.cs create mode 100644 tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Client/HomeMaticXmlRpcApiBuilderLinkSurfaceTests.cs create mode 100644 tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Client/HomeMaticXmlRpcApiLinkExtensionsTests.cs create mode 100644 tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Converters/FlagsMemberValueConverterLinkFlagsTests.cs create mode 100644 tests/CreativeCoders.HomeMatic.XmlRpc.Tests/CreativeCoders.HomeMatic.XmlRpc.Tests.csproj create mode 100644 tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Links/GetLinksFlagsTests.cs create mode 100644 tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Links/LinkFlagsTests.cs create mode 100644 tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Links/LinkTests.cs diff --git a/HomeMatic.sln b/HomeMatic.sln index 15c4af7..464f2a1 100644 --- a/HomeMatic.sln +++ b/HomeMatic.sln @@ -82,6 +82,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__docs", "__docs", "{246443 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.HomeMatic.Tools.Cli.Base.Tests", "tests\CreativeCoders.HomeMatic.Tools.Cli.Base.Tests\CreativeCoders.HomeMatic.Tools.Cli.Base.Tests.csproj", "{E3F3D28A-919C-4283-AEBD-A915A3EFE047}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.HomeMatic.XmlRpc.Tests", "tests\CreativeCoders.HomeMatic.XmlRpc.Tests\CreativeCoders.HomeMatic.XmlRpc.Tests.csproj", "{5614CD87-E146-4D66-A199-5F110B2A0445}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -234,6 +236,18 @@ Global {E3F3D28A-919C-4283-AEBD-A915A3EFE047}.Release|x64.Build.0 = Release|Any CPU {E3F3D28A-919C-4283-AEBD-A915A3EFE047}.Release|x86.ActiveCfg = Release|Any CPU {E3F3D28A-919C-4283-AEBD-A915A3EFE047}.Release|x86.Build.0 = Release|Any CPU + {5614CD87-E146-4D66-A199-5F110B2A0445}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5614CD87-E146-4D66-A199-5F110B2A0445}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5614CD87-E146-4D66-A199-5F110B2A0445}.Debug|x64.ActiveCfg = Debug|Any CPU + {5614CD87-E146-4D66-A199-5F110B2A0445}.Debug|x64.Build.0 = Debug|Any CPU + {5614CD87-E146-4D66-A199-5F110B2A0445}.Debug|x86.ActiveCfg = Debug|Any CPU + {5614CD87-E146-4D66-A199-5F110B2A0445}.Debug|x86.Build.0 = Debug|Any CPU + {5614CD87-E146-4D66-A199-5F110B2A0445}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5614CD87-E146-4D66-A199-5F110B2A0445}.Release|Any CPU.Build.0 = Release|Any CPU + {5614CD87-E146-4D66-A199-5F110B2A0445}.Release|x64.ActiveCfg = Release|Any CPU + {5614CD87-E146-4D66-A199-5F110B2A0445}.Release|x64.Build.0 = Release|Any CPU + {5614CD87-E146-4D66-A199-5F110B2A0445}.Release|x86.ActiveCfg = Release|Any CPU + {5614CD87-E146-4D66-A199-5F110B2A0445}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -255,6 +269,7 @@ Global {A6E76585-17FA-4F7F-A8EE-C4C517C0ABEE} = {B79F3B3E-C9CE-4629-ADE3-B1659AF9C673} {822ECD72-5DB0-4637-B794-CE27B02827AC} = {B79F3B3E-C9CE-4629-ADE3-B1659AF9C673} {E3F3D28A-919C-4283-AEBD-A915A3EFE047} = {5BD797BA-4D66-4F55-A55E-5F1063678D8B} + {5614CD87-E146-4D66-A199-5F110B2A0445} = {5BD797BA-4D66-4F55-A55E-5F1063678D8B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3E5E58EB-0096-4ED2-B1DE-D7FC5951CAB7} diff --git a/source/CreativeCoders.HomeMatic.XmlRpc/Client/HomeMaticXmlRpcApiLinkExtensions.cs b/source/CreativeCoders.HomeMatic.XmlRpc/Client/HomeMaticXmlRpcApiLinkExtensions.cs new file mode 100644 index 0000000..d23ef33 --- /dev/null +++ b/source/CreativeCoders.HomeMatic.XmlRpc/Client/HomeMaticXmlRpcApiLinkExtensions.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CreativeCoders.Core; +using CreativeCoders.HomeMatic.XmlRpc.Links; +using JetBrains.Annotations; + +namespace CreativeCoders.HomeMatic.XmlRpc.Client; + +/// +/// Provides strongly-typed convenience overloads for the link-related methods of +/// . +/// +[PublicAPI] +public static class HomeMaticXmlRpcApiLinkExtensions +{ + /// + /// Retrieves all communication links assigned to a logical device or channel using a + /// strongly-typed argument. + /// + /// The API instance to invoke. + /// + /// The channel or device address. Pass an empty string to retrieve all links of the entire + /// interface process. + /// + /// A bitwise combination of values. + /// A collection of structures describing each link. + public static Task> GetLinksAsync(this IHomeMaticXmlRpcApi api, string address, + GetLinksFlags flags = GetLinksFlags.None) + { + Ensure.NotNull(api); + Ensure.NotNull(address); + + return api.GetLinksAsync(address, (int) flags); + } + + /// + /// Retrieves the descriptive information of an existing communication link as a + /// strongly-typed instance. + /// + /// The API instance to invoke. + /// The address of the sender of the link. + /// The address of the receiver of the link. + /// + /// A instance whose and + /// are populated from the XML-RPC response. If the response + /// contains fewer than two entries, the missing fields default to an empty string. + /// + public static async Task GetLinkInfoAsync(this IHomeMaticXmlRpcApi api, + string senderAddress, string receiverAddress) + { + Ensure.NotNull(api); + Ensure.NotNull(senderAddress); + Ensure.NotNull(receiverAddress); + + var raw = (await api.GetLinkInfoRawAsync(senderAddress, receiverAddress).ConfigureAwait(false)) + ?.ToArray() ?? []; + + return new LinkInfo + { + Name = raw.Length > 0 ? raw[0] ?? string.Empty : string.Empty, + Description = raw.Length > 1 ? raw[1] ?? string.Empty : string.Empty + }; + } +} diff --git a/source/CreativeCoders.HomeMatic.XmlRpc/Client/IHomeMaticXmlRpcApi.cs b/source/CreativeCoders.HomeMatic.XmlRpc/Client/IHomeMaticXmlRpcApi.cs index 0c9bb3d..cc36169 100644 --- a/source/CreativeCoders.HomeMatic.XmlRpc/Client/IHomeMaticXmlRpcApi.cs +++ b/source/CreativeCoders.HomeMatic.XmlRpc/Client/IHomeMaticXmlRpcApi.cs @@ -1,5 +1,7 @@ using System.Collections.Generic; +using System.ComponentModel; using System.Threading.Tasks; +using CreativeCoders.HomeMatic.XmlRpc.Links; using CreativeCoders.Net.XmlRpc.Definition; using JetBrains.Annotations; @@ -170,4 +172,100 @@ public interface IHomeMaticXmlRpcApi /// The version string of the interface process. [XmlRpcMethod("getVersion")] Task GetVersionAsync(); + + /// + /// Retrieves all communication links assigned to a logical device or channel. + /// + /// + /// The channel or device address. Pass an empty string to retrieve all links of the entire + /// interface process. + /// + /// + /// A bitwise combination of values cast to . See + /// + /// for a strongly-typed overload. + /// + /// A collection of structures describing each link. + /// See section 4.2.10 of the HomeMatic XML-RPC specification. + [XmlRpcMethod("getLinks")] + Task> GetLinksAsync(string address, int flags); + + /// + /// Creates a communication link between two logical devices or channels. + /// + /// The address of the sender of the link. + /// The address of the receiver of the link. + /// An optional name for the link. Pass an empty string when not used. + /// An optional description for the link. Pass an empty string when not used. + /// See section 4.2.11 of the HomeMatic XML-RPC specification. + [XmlRpcMethod("addLink")] + Task AddLinkAsync(string sender, string receiver, string name, string description); + + /// + /// Removes the communication link between two logical devices or channels. + /// + /// The address of the sender of the link. + /// The address of the receiver of the link. + /// See section 4.2.12 of the HomeMatic XML-RPC specification. + [XmlRpcMethod("removeLink")] + Task RemoveLinkAsync(string sender, string receiver); + + /// + /// Updates the descriptive texts of an existing communication link. + /// + /// The address of the sender of the link. + /// The address of the receiver of the link. + /// The new name of the link. + /// The new description of the link. + /// + /// See section 4.3.1 of the HomeMatic XML-RPC specification. This method is supported by + /// BidCoS-RF and BidCoS-Wired interface processes. + /// + [XmlRpcMethod("setLinkInfo")] + Task SetLinkInfoAsync(string sender, string receiver, string name, string description); + + /// + /// Retrieves the raw [name, description] tuple of an existing communication link as + /// returned by the XML-RPC interface. + /// + /// The address of the sender of the link. + /// The address of the receiver of the link. + /// A two-element string sequence: the link name followed by the link description. + /// + /// See section 4.3.2 of the HomeMatic XML-RPC specification. Prefer the strongly-typed + /// extension method + /// + /// which returns a instance. + /// + [EditorBrowsable(EditorBrowsableState.Advanced)] + [XmlRpcMethod("getLinkInfo")] + Task> GetLinkInfoRawAsync(string senderAddress, string receiverAddress); + + /// + /// Activates a link parameter set so that the logical device behaves as if it had been + /// triggered directly by the assigned communication partner. + /// + /// The address of the logical device whose link parameter set should be activated. + /// The address of the communication partner whose link parameter set is activated. + /// + /// to activate the parameter set for a long key press; otherwise . + /// + /// + /// See section 4.3.3 of the HomeMatic XML-RPC specification. This method is supported by + /// BidCoS-RF interface processes only. + /// + [XmlRpcMethod("activateLinkParamset")] + Task ActivateLinkParamsetAsync(string address, string peerAddress, bool longPress); + + /// + /// Retrieves all communication partners assigned to a logical device. + /// + /// The address of the logical device. + /// + /// A collection of peer addresses. Each entry can be used as the paramSetKey argument + /// of and . + /// + /// See section 4.3.20 of the HomeMatic XML-RPC specification. + [XmlRpcMethod("getLinkPeers")] + Task> GetLinkPeersAsync(string address); } \ No newline at end of file diff --git a/source/CreativeCoders.HomeMatic.XmlRpc/Links/GetLinksFlags.cs b/source/CreativeCoders.HomeMatic.XmlRpc/Links/GetLinksFlags.cs new file mode 100644 index 0000000..47e9469 --- /dev/null +++ b/source/CreativeCoders.HomeMatic.XmlRpc/Links/GetLinksFlags.cs @@ -0,0 +1,39 @@ +using System; +using JetBrains.Annotations; + +namespace CreativeCoders.HomeMatic.XmlRpc.Links; + +/// +/// Specifies the option flags for the getLinks XML-RPC method. +/// +/// +/// Values can be combined with bitwise OR. See section 4.2.10 of the HomeMatic XML-RPC +/// specification for details. +/// +[PublicAPI] +[Flags] +public enum GetLinksFlags +{ + /// + /// No optional fields are requested. The default behaviour. + /// + None = 0, + + /// + /// Returns the links of all channels in the same group when the address denotes a grouped channel + /// (GL_FLAG_GROUP). + /// + Group = 1, + + /// + /// Includes the SENDER_PARAMSET field in the returned structures + /// (GL_FLAG_SENDER_PARAMSET). + /// + SenderParamSet = 2, + + /// + /// Includes the RECEIVER_PARAMSET field in the returned structures + /// (GL_FLAG_RECEIVER_PARAMSET). + /// + ReceiverParamSet = 4 +} diff --git a/source/CreativeCoders.HomeMatic.XmlRpc/Links/Link.cs b/source/CreativeCoders.HomeMatic.XmlRpc/Links/Link.cs new file mode 100644 index 0000000..08bb8b9 --- /dev/null +++ b/source/CreativeCoders.HomeMatic.XmlRpc/Links/Link.cs @@ -0,0 +1,78 @@ +using System.Collections.Generic; +using CreativeCoders.HomeMatic.XmlRpc.Converters; +using CreativeCoders.Net.XmlRpc.Definition; +using JetBrains.Annotations; + +namespace CreativeCoders.HomeMatic.XmlRpc.Links; + +/// +/// Describes a HomeMatic communication link between two logical devices or channels as returned +/// by the getLinks XML-RPC method. +/// +/// +/// See section 4.2.10 of the HomeMatic XML-RPC specification. The +/// and fields are only populated when the corresponding +/// or flag +/// is passed to getLinks; otherwise they default to an empty dictionary. +/// +[PublicAPI] +public class Link +{ + /// + /// Gets or sets the address of the sender of this communication link. + /// + /// The channel or device address of the sender (e.g. ABC1234567:1). + [XmlRpcStructMember("SENDER", Required = true)] + public string Sender { get; set; } = string.Empty; + + /// + /// Gets or sets the address of the receiver of this communication link. + /// + /// The channel or device address of the receiver (e.g. ABC1234567:2). + [XmlRpcStructMember("RECEIVER", Required = true)] + public string Receiver { get; set; } = string.Empty; + + /// + /// Gets or sets the state flags of this communication link. + /// + /// A bitwise combination of values. + [XmlRpcStructMember("FLAGS", DefaultValue = LinkFlags.None, + Converter = typeof(FlagsMemberValueConverter))] + public LinkFlags Flags { get; set; } + + /// + /// Gets or sets the human-readable name of this communication link. + /// + /// The link name; an empty string if not set. + [XmlRpcStructMember("NAME", DefaultValue = "")] + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the textual description of this communication link. + /// + /// The link description; an empty string if not set. + [XmlRpcStructMember("DESCRIPTION", DefaultValue = "")] + public string Description { get; set; } = string.Empty; + + /// + /// Gets or sets the parameter set associated with the sender side of this communication link. + /// + /// + /// A dictionary mapping parameter names to their current values. Only populated when the + /// flag was passed to getLinks; otherwise + /// an empty dictionary. + /// + [XmlRpcStructMember("SENDER_PARAMSET")] + public Dictionary SenderParamSet { get; set; } = new(); + + /// + /// Gets or sets the parameter set associated with the receiver side of this communication link. + /// + /// + /// A dictionary mapping parameter names to their current values. Only populated when the + /// flag was passed to getLinks; otherwise + /// an empty dictionary. + /// + [XmlRpcStructMember("RECEIVER_PARAMSET")] + public Dictionary ReceiverParamSet { get; set; } = new(); +} diff --git a/source/CreativeCoders.HomeMatic.XmlRpc/Links/LinkFlags.cs b/source/CreativeCoders.HomeMatic.XmlRpc/Links/LinkFlags.cs new file mode 100644 index 0000000..2a48ad5 --- /dev/null +++ b/source/CreativeCoders.HomeMatic.XmlRpc/Links/LinkFlags.cs @@ -0,0 +1,32 @@ +using System; +using JetBrains.Annotations; + +namespace CreativeCoders.HomeMatic.XmlRpc.Links; + +/// +/// Specifies the state of a HomeMatic communication link as reported in the FLAGS +/// field of a structure. +/// +/// +/// Values can be combined with bitwise OR. See section 4.2.10 of the HomeMatic XML-RPC +/// specification. +/// +[PublicAPI] +[Flags] +public enum LinkFlags +{ + /// + /// The link is intact on both sides. + /// + None = 0, + + /// + /// The link is broken on the sender side (LINK_FLAG_SENDER_BROKEN). + /// + SenderBroken = 1, + + /// + /// The link is broken on the receiver side (LINK_FLAG_RECEIVER_BROKEN). + /// + ReceiverBroken = 2 +} diff --git a/source/CreativeCoders.HomeMatic.XmlRpc/Links/LinkInfo.cs b/source/CreativeCoders.HomeMatic.XmlRpc/Links/LinkInfo.cs new file mode 100644 index 0000000..3ef80f0 --- /dev/null +++ b/source/CreativeCoders.HomeMatic.XmlRpc/Links/LinkInfo.cs @@ -0,0 +1,27 @@ +using JetBrains.Annotations; + +namespace CreativeCoders.HomeMatic.XmlRpc.Links; + +/// +/// Represents the descriptive information of a HomeMatic communication link as returned by +/// the getLinkInfo XML-RPC method. +/// +/// +/// The XML-RPC specification (section 4.3.2) returns this information as a string array of the +/// form [name, description]. This SDK exposes it as a dedicated type for clarity. +/// +[PublicAPI] +public class LinkInfo +{ + /// + /// Gets or sets the human-readable name of the communication link. + /// + /// The link name; an empty string if not set. + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the textual description of the communication link. + /// + /// The link description; an empty string if not set. + public string Description { get; set; } = string.Empty; +} diff --git a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Client/HomeMaticXmlRpcApiBuilderLinkSurfaceTests.cs b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Client/HomeMaticXmlRpcApiBuilderLinkSurfaceTests.cs new file mode 100644 index 0000000..dce2130 --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Client/HomeMaticXmlRpcApiBuilderLinkSurfaceTests.cs @@ -0,0 +1,40 @@ +using CreativeCoders.HomeMatic.XmlRpc.Client; +using CreativeCoders.HomeMatic.XmlRpc.Links; +using CreativeCoders.Net.XmlRpc.Proxy; +using FakeItEasy; +using AwesomeAssertions; + +namespace CreativeCoders.HomeMatic.XmlRpc.Tests.Client; + +public class HomeMaticXmlRpcApiBuilderLinkSurfaceTests +{ + [Fact] + public void Builder_BuildsProxy_WithLinkMethodsAvailable() + { + var proxyBuilder = A.Fake>(); + var fakeApi = A.Fake(); + + A.CallTo(() => proxyBuilder.ForUrl(A._)).Returns(proxyBuilder); + A.CallTo(() => proxyBuilder.Build()).Returns(fakeApi); + + var sut = new HomeMaticXmlRpcApiBuilder(proxyBuilder); + + var api = sut.ForUrl(new Uri("http://localhost:2001/")).Build(); + + api.Should().BeAssignableTo(); + } + + [Fact] + public void IHomeMaticXmlRpcApi_ExposesAllLinkMethods() + { + var type = typeof(IHomeMaticXmlRpcApi); + + type.GetMethod(nameof(IHomeMaticXmlRpcApi.GetLinksAsync)).Should().NotBeNull(); + type.GetMethod(nameof(IHomeMaticXmlRpcApi.AddLinkAsync)).Should().NotBeNull(); + type.GetMethod(nameof(IHomeMaticXmlRpcApi.RemoveLinkAsync)).Should().NotBeNull(); + type.GetMethod(nameof(IHomeMaticXmlRpcApi.SetLinkInfoAsync)).Should().NotBeNull(); + type.GetMethod(nameof(IHomeMaticXmlRpcApi.GetLinkInfoRawAsync)).Should().NotBeNull(); + type.GetMethod(nameof(IHomeMaticXmlRpcApi.ActivateLinkParamsetAsync)).Should().NotBeNull(); + type.GetMethod(nameof(IHomeMaticXmlRpcApi.GetLinkPeersAsync)).Should().NotBeNull(); + } +} diff --git a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Client/HomeMaticXmlRpcApiLinkExtensionsTests.cs b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Client/HomeMaticXmlRpcApiLinkExtensionsTests.cs new file mode 100644 index 0000000..e0331bf --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Client/HomeMaticXmlRpcApiLinkExtensionsTests.cs @@ -0,0 +1,75 @@ +using CreativeCoders.HomeMatic.XmlRpc.Client; +using CreativeCoders.HomeMatic.XmlRpc.Links; +using FakeItEasy; +using AwesomeAssertions; + +namespace CreativeCoders.HomeMatic.XmlRpc.Tests.Client; + +public class HomeMaticXmlRpcApiLinkExtensionsTests +{ + [Fact] + public async Task GetLinksAsync_TypedFlags_ForwardsBitmaskToApi() + { + var api = A.Fake(); + A.CallTo(() => api.GetLinksAsync("ABC1234567:1", A._)) + .Returns(Task.FromResult(Enumerable.Empty())); + + await api.GetLinksAsync("ABC1234567:1", + GetLinksFlags.Group | GetLinksFlags.SenderParamSet); + + A.CallTo(() => api.GetLinksAsync("ABC1234567:1", 3)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task GetLinksAsync_DefaultFlags_PassesZero() + { + var api = A.Fake(); + A.CallTo(() => api.GetLinksAsync(A._, A._)) + .Returns(Task.FromResult(Enumerable.Empty())); + + await api.GetLinksAsync("ABC1234567"); + + A.CallTo(() => api.GetLinksAsync("ABC1234567", 0)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task GetLinkInfoAsync_TwoElementResponse_MapsToNameAndDescription() + { + var api = A.Fake(); + A.CallTo(() => api.GetLinkInfoRawAsync("S", "R")) + .Returns(Task.FromResult>(["MyLink", "Some description"])); + + var info = await api.GetLinkInfoAsync("S", "R"); + + info.Name.Should().Be("MyLink"); + info.Description.Should().Be("Some description"); + } + + [Fact] + public async Task GetLinkInfoAsync_EmptyResponse_ReturnsEmptyStrings() + { + var api = A.Fake(); + A.CallTo(() => api.GetLinkInfoRawAsync("S", "R")) + .Returns(Task.FromResult>([])); + + var info = await api.GetLinkInfoAsync("S", "R"); + + info.Name.Should().BeEmpty(); + info.Description.Should().BeEmpty(); + } + + [Fact] + public async Task GetLinkInfoAsync_SingleElementResponse_DescriptionEmpty() + { + var api = A.Fake(); + A.CallTo(() => api.GetLinkInfoRawAsync("S", "R")) + .Returns(Task.FromResult>(["JustName"])); + + var info = await api.GetLinkInfoAsync("S", "R"); + + info.Name.Should().Be("JustName"); + info.Description.Should().BeEmpty(); + } +} diff --git a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Converters/FlagsMemberValueConverterLinkFlagsTests.cs b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Converters/FlagsMemberValueConverterLinkFlagsTests.cs new file mode 100644 index 0000000..45f8bea --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Converters/FlagsMemberValueConverterLinkFlagsTests.cs @@ -0,0 +1,33 @@ +using CreativeCoders.HomeMatic.XmlRpc.Converters; +using CreativeCoders.HomeMatic.XmlRpc.Links; +using CreativeCoders.Net.XmlRpc.Model.Values; +using AwesomeAssertions; + +namespace CreativeCoders.HomeMatic.XmlRpc.Tests.Converters; + +public class FlagsMemberValueConverterLinkFlagsTests +{ + private readonly FlagsMemberValueConverter _sut = new(); + + [Theory] + [InlineData(0, LinkFlags.None)] + [InlineData(1, LinkFlags.SenderBroken)] + [InlineData(2, LinkFlags.ReceiverBroken)] + [InlineData(3, LinkFlags.SenderBroken | LinkFlags.ReceiverBroken)] + public void ConvertFromValue_IntegerValue_ReturnsLinkFlags(int raw, LinkFlags expected) + { + var result = _sut.ConvertFromValue(new IntegerValue(raw)); + + result.Should().Be(expected); + } + + [Fact] + public void ConvertFromValue_NonIntegerValue_ReturnsRawData() + { + var stringValue = new StringValue("hello"); + + var result = _sut.ConvertFromValue(stringValue); + + result.Should().Be(stringValue.Data); + } +} diff --git a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/CreativeCoders.HomeMatic.XmlRpc.Tests.csproj b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/CreativeCoders.HomeMatic.XmlRpc.Tests.csproj new file mode 100644 index 0000000..409ed1c --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/CreativeCoders.HomeMatic.XmlRpc.Tests.csproj @@ -0,0 +1,35 @@ + + + + enable + enable + + false + true + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + diff --git a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Links/GetLinksFlagsTests.cs b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Links/GetLinksFlagsTests.cs new file mode 100644 index 0000000..647cf79 --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Links/GetLinksFlagsTests.cs @@ -0,0 +1,28 @@ +using CreativeCoders.HomeMatic.XmlRpc.Links; +using AwesomeAssertions; + +namespace CreativeCoders.HomeMatic.XmlRpc.Tests.Links; + +public class GetLinksFlagsTests +{ + [Theory] + [InlineData(GetLinksFlags.None, 0)] + [InlineData(GetLinksFlags.Group, 1)] + [InlineData(GetLinksFlags.SenderParamSet, 2)] + [InlineData(GetLinksFlags.ReceiverParamSet, 4)] + public void EnumValue_MatchesSpecBitmask(GetLinksFlags flag, int expected) + { + ((int) flag).Should().Be(expected); + } + + [Fact] + public void AllFlags_Combined_EqualsSeven() + { + var combined = GetLinksFlags.Group | GetLinksFlags.SenderParamSet | GetLinksFlags.ReceiverParamSet; + + ((int) combined).Should().Be(7); + combined.HasFlag(GetLinksFlags.Group).Should().BeTrue(); + combined.HasFlag(GetLinksFlags.SenderParamSet).Should().BeTrue(); + combined.HasFlag(GetLinksFlags.ReceiverParamSet).Should().BeTrue(); + } +} diff --git a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Links/LinkFlagsTests.cs b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Links/LinkFlagsTests.cs new file mode 100644 index 0000000..090d43a --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Links/LinkFlagsTests.cs @@ -0,0 +1,35 @@ +using CreativeCoders.HomeMatic.XmlRpc.Links; +using AwesomeAssertions; + +namespace CreativeCoders.HomeMatic.XmlRpc.Tests.Links; + +public class LinkFlagsTests +{ + [Fact] + public void None_HasIntegerValue_Zero() + { + ((int) LinkFlags.None).Should().Be(0); + } + + [Fact] + public void SenderBroken_HasIntegerValue_One() + { + ((int) LinkFlags.SenderBroken).Should().Be(1); + } + + [Fact] + public void ReceiverBroken_HasIntegerValue_Two() + { + ((int) LinkFlags.ReceiverBroken).Should().Be(2); + } + + [Fact] + public void CombinedFlags_AreReportedCorrectly() + { + var flags = LinkFlags.SenderBroken | LinkFlags.ReceiverBroken; + + flags.HasFlag(LinkFlags.SenderBroken).Should().BeTrue(); + flags.HasFlag(LinkFlags.ReceiverBroken).Should().BeTrue(); + ((int) flags).Should().Be(3); + } +} diff --git a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Links/LinkTests.cs b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Links/LinkTests.cs new file mode 100644 index 0000000..2b5cc37 --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Links/LinkTests.cs @@ -0,0 +1,33 @@ +using CreativeCoders.HomeMatic.XmlRpc.Links; +using AwesomeAssertions; + +namespace CreativeCoders.HomeMatic.XmlRpc.Tests.Links; + +public class LinkTests +{ + [Fact] + public void DefaultInstance_HasEmptyParamSets_AndNoNullProperties() + { + var link = new Link(); + + link.Sender.Should().BeEmpty(); + link.Receiver.Should().BeEmpty(); + link.Name.Should().BeEmpty(); + link.Description.Should().BeEmpty(); + link.Flags.Should().Be(LinkFlags.None); + link.SenderParamSet.Should().NotBeNull().And.BeEmpty(); + link.ReceiverParamSet.Should().NotBeNull().And.BeEmpty(); + } + + [Fact] + public void ParamSets_AreMutable() + { + var link = new Link(); + + link.SenderParamSet["TEMPERATURE"] = 21.5; + link.ReceiverParamSet["LEVEL"] = 0.7; + + link.SenderParamSet.Should().ContainKey("TEMPERATURE").WhoseValue.Should().Be(21.5); + link.ReceiverParamSet.Should().ContainKey("LEVEL").WhoseValue.Should().Be(0.7); + } +} From 0746425b31608c47650b0e524b7d7fd44ebad899 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:12:58 +0200 Subject: [PATCH 11/16] refactor(tests): enhance test coverage and remove obsolete `LinkSurface` tests - Replaced `HomeMaticXmlRpcApiBuilderLinkSurfaceTests` with an extended `HomeMaticXmlRpcApiBuilderTests` suite for improved test clarity and edge case handling. - Added null argument checks and error scenario validations for `HomeMaticXmlRpcApiBuilder` and `HomeMaticXmlRpcApiLinkExtensions`. - Improved bitmask flag tests in `GetLinksFlagsTests` and `LinkFlagsTests` to ensure completeness. - Refactored test structure and added assertion comments for better readability. --- ...meMaticXmlRpcApiBuilderLinkSurfaceTests.cs | 40 ----- .../Client/HomeMaticXmlRpcApiBuilderTests.cs | 86 +++++++++++ .../HomeMaticXmlRpcApiLinkExtensionsTests.cs | 144 ++++++++++++++++++ ...FlagsMemberValueConverterLinkFlagsTests.cs | 28 +++- .../Links/GetLinksFlagsTests.cs | 14 +- .../Links/LinkFlagsTests.cs | 22 +-- .../Links/LinkTests.cs | 9 +- 7 files changed, 267 insertions(+), 76 deletions(-) delete mode 100644 tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Client/HomeMaticXmlRpcApiBuilderLinkSurfaceTests.cs create mode 100644 tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Client/HomeMaticXmlRpcApiBuilderTests.cs diff --git a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Client/HomeMaticXmlRpcApiBuilderLinkSurfaceTests.cs b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Client/HomeMaticXmlRpcApiBuilderLinkSurfaceTests.cs deleted file mode 100644 index dce2130..0000000 --- a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Client/HomeMaticXmlRpcApiBuilderLinkSurfaceTests.cs +++ /dev/null @@ -1,40 +0,0 @@ -using CreativeCoders.HomeMatic.XmlRpc.Client; -using CreativeCoders.HomeMatic.XmlRpc.Links; -using CreativeCoders.Net.XmlRpc.Proxy; -using FakeItEasy; -using AwesomeAssertions; - -namespace CreativeCoders.HomeMatic.XmlRpc.Tests.Client; - -public class HomeMaticXmlRpcApiBuilderLinkSurfaceTests -{ - [Fact] - public void Builder_BuildsProxy_WithLinkMethodsAvailable() - { - var proxyBuilder = A.Fake>(); - var fakeApi = A.Fake(); - - A.CallTo(() => proxyBuilder.ForUrl(A._)).Returns(proxyBuilder); - A.CallTo(() => proxyBuilder.Build()).Returns(fakeApi); - - var sut = new HomeMaticXmlRpcApiBuilder(proxyBuilder); - - var api = sut.ForUrl(new Uri("http://localhost:2001/")).Build(); - - api.Should().BeAssignableTo(); - } - - [Fact] - public void IHomeMaticXmlRpcApi_ExposesAllLinkMethods() - { - var type = typeof(IHomeMaticXmlRpcApi); - - type.GetMethod(nameof(IHomeMaticXmlRpcApi.GetLinksAsync)).Should().NotBeNull(); - type.GetMethod(nameof(IHomeMaticXmlRpcApi.AddLinkAsync)).Should().NotBeNull(); - type.GetMethod(nameof(IHomeMaticXmlRpcApi.RemoveLinkAsync)).Should().NotBeNull(); - type.GetMethod(nameof(IHomeMaticXmlRpcApi.SetLinkInfoAsync)).Should().NotBeNull(); - type.GetMethod(nameof(IHomeMaticXmlRpcApi.GetLinkInfoRawAsync)).Should().NotBeNull(); - type.GetMethod(nameof(IHomeMaticXmlRpcApi.ActivateLinkParamsetAsync)).Should().NotBeNull(); - type.GetMethod(nameof(IHomeMaticXmlRpcApi.GetLinkPeersAsync)).Should().NotBeNull(); - } -} diff --git a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Client/HomeMaticXmlRpcApiBuilderTests.cs b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Client/HomeMaticXmlRpcApiBuilderTests.cs new file mode 100644 index 0000000..eabb1fb --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Client/HomeMaticXmlRpcApiBuilderTests.cs @@ -0,0 +1,86 @@ +using CreativeCoders.HomeMatic.XmlRpc.Client; +using CreativeCoders.Net.XmlRpc.Proxy; +using FakeItEasy; +using AwesomeAssertions; + +namespace CreativeCoders.HomeMatic.XmlRpc.Tests.Client; + +public class HomeMaticXmlRpcApiBuilderTests +{ + [Fact] + public void Constructor_NullProxyBuilder_ThrowsArgumentNullException() + { + // Arrange & Act + Action act = () => new HomeMaticXmlRpcApiBuilder(null!); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void ForUrl_NullUri_ThrowsArgumentNullException() + { + // Arrange + var proxyBuilder = A.Fake>(); + var sut = new HomeMaticXmlRpcApiBuilder(proxyBuilder); + + // Act + Action act = () => sut.ForUrl((Uri) null!); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Build_AfterForUrl_DelegatesToProxyBuilderWithConfiguredUrl() + { + // Arrange + var proxyBuilder = A.Fake>(); + var fakeApi = A.Fake(); + var url = new Uri("http://localhost:2001/"); + A.CallTo(() => proxyBuilder.ForUrl(url)).Returns(proxyBuilder); + A.CallTo(() => proxyBuilder.Build()).Returns(fakeApi); + var sut = new HomeMaticXmlRpcApiBuilder(proxyBuilder); + + // Act + var api = sut.ForUrl(url).Build(); + + // Assert + api.Should().BeSameAs(fakeApi); + A.CallTo(() => proxyBuilder.ForUrl(url)).MustHaveHappenedOnceExactly(); + A.CallTo(() => proxyBuilder.Build()).MustHaveHappenedOnceExactly(); + } + + [Fact] + public void Build_WithoutForUrl_ThrowsInvalidOperationException() + { + // Arrange + var proxyBuilder = A.Fake>(); + var sut = new HomeMaticXmlRpcApiBuilder(proxyBuilder); + + // Act + Action act = () => sut.Build(); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void ForUrl_XmlRpcApiAddress_DelegatesToUriOverloadWithDerivedUrl() + { + // Arrange + var proxyBuilder = A.Fake>(); + var fakeApi = A.Fake(); + A.CallTo(() => proxyBuilder.ForUrl(A._)).Returns(proxyBuilder); + A.CallTo(() => proxyBuilder.Build()).Returns(fakeApi); + var apiAddress = new XmlRpcApiAddress(new Uri("http://192.168.1.100/"), CcuDeviceKind.HomeMatic); + var expectedUrl = apiAddress.ToApiUrl(); + var sut = new HomeMaticXmlRpcApiBuilder(proxyBuilder); + + // Act + sut.ForUrl(apiAddress).Build(); + + // Assert + A.CallTo(() => proxyBuilder.ForUrl(expectedUrl)).MustHaveHappenedOnceExactly(); + } +} diff --git a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Client/HomeMaticXmlRpcApiLinkExtensionsTests.cs b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Client/HomeMaticXmlRpcApiLinkExtensionsTests.cs index e0331bf..8ffcd7c 100644 --- a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Client/HomeMaticXmlRpcApiLinkExtensionsTests.cs +++ b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Client/HomeMaticXmlRpcApiLinkExtensionsTests.cs @@ -10,13 +10,16 @@ public class HomeMaticXmlRpcApiLinkExtensionsTests [Fact] public async Task GetLinksAsync_TypedFlags_ForwardsBitmaskToApi() { + // Arrange var api = A.Fake(); A.CallTo(() => api.GetLinksAsync("ABC1234567:1", A._)) .Returns(Task.FromResult(Enumerable.Empty())); + // Act await api.GetLinksAsync("ABC1234567:1", GetLinksFlags.Group | GetLinksFlags.SenderParamSet); + // Assert A.CallTo(() => api.GetLinksAsync("ABC1234567:1", 3)) .MustHaveHappenedOnceExactly(); } @@ -24,25 +27,73 @@ await api.GetLinksAsync("ABC1234567:1", [Fact] public async Task GetLinksAsync_DefaultFlags_PassesZero() { + // Arrange var api = A.Fake(); A.CallTo(() => api.GetLinksAsync(A._, A._)) .Returns(Task.FromResult(Enumerable.Empty())); + // Act await api.GetLinksAsync("ABC1234567"); + // Assert A.CallTo(() => api.GetLinksAsync("ABC1234567", 0)) .MustHaveHappenedOnceExactly(); } + [Fact] + public async Task GetLinksAsync_EmptyAddress_ForwardsEmptyAddressToApi() + { + // Arrange + var api = A.Fake(); + A.CallTo(() => api.GetLinksAsync(A._, A._)) + .Returns(Task.FromResult(Enumerable.Empty())); + + // Act + await api.GetLinksAsync(string.Empty, GetLinksFlags.Group); + + // Assert + A.CallTo(() => api.GetLinksAsync(string.Empty, 1)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public void GetLinksAsync_NullApi_ThrowsArgumentNullException() + { + // Arrange + IHomeMaticXmlRpcApi api = null!; + + // Act + Action act = () => api.GetLinksAsync("ABC1234567"); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void GetLinksAsync_NullAddress_ThrowsArgumentNullException() + { + // Arrange + var api = A.Fake(); + + // Act + Action act = () => api.GetLinksAsync(null!); + + // Assert + act.Should().Throw(); + } + [Fact] public async Task GetLinkInfoAsync_TwoElementResponse_MapsToNameAndDescription() { + // Arrange var api = A.Fake(); A.CallTo(() => api.GetLinkInfoRawAsync("S", "R")) .Returns(Task.FromResult>(["MyLink", "Some description"])); + // Act var info = await api.GetLinkInfoAsync("S", "R"); + // Assert info.Name.Should().Be("MyLink"); info.Description.Should().Be("Some description"); } @@ -50,12 +101,15 @@ public async Task GetLinkInfoAsync_TwoElementResponse_MapsToNameAndDescription() [Fact] public async Task GetLinkInfoAsync_EmptyResponse_ReturnsEmptyStrings() { + // Arrange var api = A.Fake(); A.CallTo(() => api.GetLinkInfoRawAsync("S", "R")) .Returns(Task.FromResult>([])); + // Act var info = await api.GetLinkInfoAsync("S", "R"); + // Assert info.Name.Should().BeEmpty(); info.Description.Should().BeEmpty(); } @@ -63,13 +117,103 @@ public async Task GetLinkInfoAsync_EmptyResponse_ReturnsEmptyStrings() [Fact] public async Task GetLinkInfoAsync_SingleElementResponse_DescriptionEmpty() { + // Arrange var api = A.Fake(); A.CallTo(() => api.GetLinkInfoRawAsync("S", "R")) .Returns(Task.FromResult>(["JustName"])); + // Act var info = await api.GetLinkInfoAsync("S", "R"); + // Assert info.Name.Should().Be("JustName"); info.Description.Should().BeEmpty(); } + + [Fact] + public async Task GetLinkInfoAsync_NullApi_ThrowsArgumentNullException() + { + // Arrange + IHomeMaticXmlRpcApi api = null!; + + // Act + Func act = () => api.GetLinkInfoAsync("S", "R"); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetLinkInfoAsync_NullSenderAddress_ThrowsArgumentNullException() + { + // Arrange + var api = A.Fake(); + + // Act + Func act = () => api.GetLinkInfoAsync(null!, "R"); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetLinkInfoAsync_NullReceiverAddress_ThrowsArgumentNullException() + { + // Arrange + var api = A.Fake(); + + // Act + Func act = () => api.GetLinkInfoAsync("S", null!); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetLinkInfoAsync_RawAsyncReturnsNull_ReturnsEmptyStrings() + { + // Arrange + var api = A.Fake(); + A.CallTo(() => api.GetLinkInfoRawAsync("S", "R")) + .Returns(Task.FromResult>(null!)); + + // Act + var info = await api.GetLinkInfoAsync("S", "R"); + + // Assert + info.Name.Should().BeEmpty(); + info.Description.Should().BeEmpty(); + } + + [Fact] + public async Task GetLinkInfoAsync_RawEntriesAreNull_NormaliseToEmptyStrings() + { + // Arrange + var api = A.Fake(); + A.CallTo(() => api.GetLinkInfoRawAsync("S", "R")) + .Returns(Task.FromResult>([null!, null!])); + + // Act + var info = await api.GetLinkInfoAsync("S", "R"); + + // Assert + info.Name.Should().BeEmpty(); + info.Description.Should().BeEmpty(); + } + + [Fact] + public async Task GetLinkInfoAsync_MoreThanTwoElements_IgnoresExtraEntries() + { + // Arrange + var api = A.Fake(); + A.CallTo(() => api.GetLinkInfoRawAsync("S", "R")) + .Returns(Task.FromResult>(["Name", "Description", "Extra1", "Extra2"])); + + // Act + var info = await api.GetLinkInfoAsync("S", "R"); + + // Assert + info.Name.Should().Be("Name"); + info.Description.Should().Be("Description"); + } } diff --git a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Converters/FlagsMemberValueConverterLinkFlagsTests.cs b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Converters/FlagsMemberValueConverterLinkFlagsTests.cs index 45f8bea..5bbb962 100644 --- a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Converters/FlagsMemberValueConverterLinkFlagsTests.cs +++ b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Converters/FlagsMemberValueConverterLinkFlagsTests.cs @@ -7,8 +7,6 @@ namespace CreativeCoders.HomeMatic.XmlRpc.Tests.Converters; public class FlagsMemberValueConverterLinkFlagsTests { - private readonly FlagsMemberValueConverter _sut = new(); - [Theory] [InlineData(0, LinkFlags.None)] [InlineData(1, LinkFlags.SenderBroken)] @@ -16,18 +14,40 @@ public class FlagsMemberValueConverterLinkFlagsTests [InlineData(3, LinkFlags.SenderBroken | LinkFlags.ReceiverBroken)] public void ConvertFromValue_IntegerValue_ReturnsLinkFlags(int raw, LinkFlags expected) { - var result = _sut.ConvertFromValue(new IntegerValue(raw)); + // Arrange + var sut = new FlagsMemberValueConverter(); + + // Act + var result = sut.ConvertFromValue(new IntegerValue(raw)); + // Assert result.Should().Be(expected); } [Fact] public void ConvertFromValue_NonIntegerValue_ReturnsRawData() { + // Arrange + var sut = new FlagsMemberValueConverter(); var stringValue = new StringValue("hello"); - var result = _sut.ConvertFromValue(stringValue); + // Act + var result = sut.ConvertFromValue(stringValue); + // Assert result.Should().Be(stringValue.Data); } + + [Fact] + public void ConvertFromObject_AnyValue_ThrowsNotImplementedException() + { + // Arrange + var sut = new FlagsMemberValueConverter(); + + // Act + Action act = () => sut.ConvertFromObject(LinkFlags.SenderBroken); + + // Assert + act.Should().Throw(); + } } diff --git a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Links/GetLinksFlagsTests.cs b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Links/GetLinksFlagsTests.cs index 647cf79..68e4815 100644 --- a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Links/GetLinksFlagsTests.cs +++ b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Links/GetLinksFlagsTests.cs @@ -5,21 +5,13 @@ namespace CreativeCoders.HomeMatic.XmlRpc.Tests.Links; public class GetLinksFlagsTests { - [Theory] - [InlineData(GetLinksFlags.None, 0)] - [InlineData(GetLinksFlags.Group, 1)] - [InlineData(GetLinksFlags.SenderParamSet, 2)] - [InlineData(GetLinksFlags.ReceiverParamSet, 4)] - public void EnumValue_MatchesSpecBitmask(GetLinksFlags flag, int expected) - { - ((int) flag).Should().Be(expected); - } - [Fact] - public void AllFlags_Combined_EqualsSeven() + public void BitwiseOr_AllFlags_ProducesBitmaskSevenAndContainsEachFlag() { + // Arrange & Act var combined = GetLinksFlags.Group | GetLinksFlags.SenderParamSet | GetLinksFlags.ReceiverParamSet; + // Assert ((int) combined).Should().Be(7); combined.HasFlag(GetLinksFlags.Group).Should().BeTrue(); combined.HasFlag(GetLinksFlags.SenderParamSet).Should().BeTrue(); diff --git a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Links/LinkFlagsTests.cs b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Links/LinkFlagsTests.cs index 090d43a..29a1229 100644 --- a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Links/LinkFlagsTests.cs +++ b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Links/LinkFlagsTests.cs @@ -6,28 +6,12 @@ namespace CreativeCoders.HomeMatic.XmlRpc.Tests.Links; public class LinkFlagsTests { [Fact] - public void None_HasIntegerValue_Zero() - { - ((int) LinkFlags.None).Should().Be(0); - } - - [Fact] - public void SenderBroken_HasIntegerValue_One() - { - ((int) LinkFlags.SenderBroken).Should().Be(1); - } - - [Fact] - public void ReceiverBroken_HasIntegerValue_Two() - { - ((int) LinkFlags.ReceiverBroken).Should().Be(2); - } - - [Fact] - public void CombinedFlags_AreReportedCorrectly() + public void BitwiseOr_SenderAndReceiverBroken_ReportsBothFlagsAndBitmaskThree() { + // Arrange & Act var flags = LinkFlags.SenderBroken | LinkFlags.ReceiverBroken; + // Assert flags.HasFlag(LinkFlags.SenderBroken).Should().BeTrue(); flags.HasFlag(LinkFlags.ReceiverBroken).Should().BeTrue(); ((int) flags).Should().Be(3); diff --git a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Links/LinkTests.cs b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Links/LinkTests.cs index 2b5cc37..8c0aed7 100644 --- a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Links/LinkTests.cs +++ b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Links/LinkTests.cs @@ -6,10 +6,12 @@ namespace CreativeCoders.HomeMatic.XmlRpc.Tests.Links; public class LinkTests { [Fact] - public void DefaultInstance_HasEmptyParamSets_AndNoNullProperties() + public void Constructor_Default_InitialisesAllPropertiesToEmptyDefaults() { + // Arrange & Act var link = new Link(); + // Assert link.Sender.Should().BeEmpty(); link.Receiver.Should().BeEmpty(); link.Name.Should().BeEmpty(); @@ -20,13 +22,16 @@ public void DefaultInstance_HasEmptyParamSets_AndNoNullProperties() } [Fact] - public void ParamSets_AreMutable() + public void ParamSets_WhenAssigned_AreMutable() { + // Arrange var link = new Link(); + // Act link.SenderParamSet["TEMPERATURE"] = 21.5; link.ReceiverParamSet["LEVEL"] = 0.7; + // Assert link.SenderParamSet.Should().ContainKey("TEMPERATURE").WhoseValue.Should().Be(21.5); link.ReceiverParamSet.Should().ContainKey("LEVEL").WhoseValue.Should().Be(0.7); } From 741541581c90214a695a8864d5e4994c7140547e Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:19:01 +0200 Subject: [PATCH 12/16] refactor(tests): remove obsolete `Links` tests and add enhanced flag conversion tests - Deleted `GetLinksFlagsTests`, `LinkFlagsTests`, and `LinkTests` as they overlap with `FlagsMemberValueConverter` and API extension tests. - Added `FlagsMemberValueConverterRxModesTests` for comprehensive RxModes conversion testing. - Expanded `HomeMaticXmlRpcApiLinkExtensionsTests` and `HomeMaticXmlRpcApiBuilderTests` for new edge cases and error propagation validation. --- .../Client/HomeMaticXmlRpcApiBuilderTests.cs | 34 +++++++++++++++++ .../HomeMaticXmlRpcApiLinkExtensionsTests.cs | 34 +++++++++++++++++ ...FlagsMemberValueConverterLinkFlagsTests.cs | 13 +++++++ .../FlagsMemberValueConverterRxModesTests.cs | 27 +++++++++++++ .../Links/GetLinksFlagsTests.cs | 20 ---------- .../Links/LinkFlagsTests.cs | 19 ---------- .../Links/LinkTests.cs | 38 ------------------- 7 files changed, 108 insertions(+), 77 deletions(-) create mode 100644 tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Converters/FlagsMemberValueConverterRxModesTests.cs delete mode 100644 tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Links/GetLinksFlagsTests.cs delete mode 100644 tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Links/LinkFlagsTests.cs delete mode 100644 tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Links/LinkTests.cs diff --git a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Client/HomeMaticXmlRpcApiBuilderTests.cs b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Client/HomeMaticXmlRpcApiBuilderTests.cs index eabb1fb..421a6c6 100644 --- a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Client/HomeMaticXmlRpcApiBuilderTests.cs +++ b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Client/HomeMaticXmlRpcApiBuilderTests.cs @@ -65,6 +65,40 @@ public void Build_WithoutForUrl_ThrowsInvalidOperationException() act.Should().Throw(); } + [Fact] + public void ForUrl_Uri_ReturnsSameBuilderInstance() + { + // Arrange + var proxyBuilder = A.Fake>(); + var sut = new HomeMaticXmlRpcApiBuilder(proxyBuilder); + + // Act + var returned = sut.ForUrl(new Uri("http://localhost:2001/")); + + // Assert + returned.Should().BeSameAs(sut); + } + + [Fact] + public void Build_AfterForUrlCalledTwice_UsesLastUrl() + { + // Arrange + var proxyBuilder = A.Fake>(); + var fakeApi = A.Fake(); + var firstUrl = new Uri("http://first.local/"); + var secondUrl = new Uri("http://second.local/"); + A.CallTo(() => proxyBuilder.ForUrl(A._)).Returns(proxyBuilder); + A.CallTo(() => proxyBuilder.Build()).Returns(fakeApi); + var sut = new HomeMaticXmlRpcApiBuilder(proxyBuilder); + + // Act + sut.ForUrl(firstUrl).ForUrl(secondUrl).Build(); + + // Assert + A.CallTo(() => proxyBuilder.ForUrl(secondUrl)).MustHaveHappenedOnceExactly(); + A.CallTo(() => proxyBuilder.ForUrl(firstUrl)).MustNotHaveHappened(); + } + [Fact] public void ForUrl_XmlRpcApiAddress_DelegatesToUriOverloadWithDerivedUrl() { diff --git a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Client/HomeMaticXmlRpcApiLinkExtensionsTests.cs b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Client/HomeMaticXmlRpcApiLinkExtensionsTests.cs index 8ffcd7c..f90500a 100644 --- a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Client/HomeMaticXmlRpcApiLinkExtensionsTests.cs +++ b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Client/HomeMaticXmlRpcApiLinkExtensionsTests.cs @@ -82,6 +82,40 @@ public void GetLinksAsync_NullAddress_ThrowsArgumentNullException() act.Should().Throw(); } + [Fact] + public async Task GetLinksAsync_UnderlyingApiThrows_PropagatesException() + { + // Arrange + var api = A.Fake(); + var failure = new InvalidOperationException("boom"); + A.CallTo(() => api.GetLinksAsync(A._, A._)).ThrowsAsync(failure); + + // Act + Func act = () => api.GetLinksAsync("ABC1234567"); + + // Assert + (await act.Should().ThrowAsync()) + .Which.Should().BeSameAs(failure); + } + + [Fact] + public async Task GetLinkInfoAsync_EmptyAddresses_ForwardsToUnderlyingApi() + { + // Arrange + var api = A.Fake(); + A.CallTo(() => api.GetLinkInfoRawAsync(string.Empty, string.Empty)) + .Returns(Task.FromResult>(["N", "D"])); + + // Act + var info = await api.GetLinkInfoAsync(string.Empty, string.Empty); + + // Assert + info.Name.Should().Be("N"); + info.Description.Should().Be("D"); + A.CallTo(() => api.GetLinkInfoRawAsync(string.Empty, string.Empty)) + .MustHaveHappenedOnceExactly(); + } + [Fact] public async Task GetLinkInfoAsync_TwoElementResponse_MapsToNameAndDescription() { diff --git a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Converters/FlagsMemberValueConverterLinkFlagsTests.cs b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Converters/FlagsMemberValueConverterLinkFlagsTests.cs index 5bbb962..f782f52 100644 --- a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Converters/FlagsMemberValueConverterLinkFlagsTests.cs +++ b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Converters/FlagsMemberValueConverterLinkFlagsTests.cs @@ -38,6 +38,19 @@ public void ConvertFromValue_NonIntegerValue_ReturnsRawData() result.Should().Be(stringValue.Data); } + [Fact] + public void ConvertFromValue_NullValue_ThrowsNullReferenceException() + { + // Arrange + var sut = new FlagsMemberValueConverter(); + + // Act + Action act = () => sut.ConvertFromValue(null!); + + // Assert + act.Should().Throw(); + } + [Fact] public void ConvertFromObject_AnyValue_ThrowsNotImplementedException() { diff --git a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Converters/FlagsMemberValueConverterRxModesTests.cs b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Converters/FlagsMemberValueConverterRxModesTests.cs new file mode 100644 index 0000000..2ff549c --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Converters/FlagsMemberValueConverterRxModesTests.cs @@ -0,0 +1,27 @@ +using CreativeCoders.HomeMatic.XmlRpc.Converters; +using CreativeCoders.HomeMatic.XmlRpc.Parameters; +using CreativeCoders.Net.XmlRpc.Model.Values; +using AwesomeAssertions; + +namespace CreativeCoders.HomeMatic.XmlRpc.Tests.Converters; + +public class FlagsMemberValueConverterRxModesTests +{ + [Theory] + [InlineData(0, RxModes.None)] + [InlineData(1, RxModes.Always)] + [InlineData(2, RxModes.Burst)] + [InlineData(10, RxModes.Burst | RxModes.WakeUp)] + [InlineData(31, RxModes.Always | RxModes.Burst | RxModes.Config | RxModes.WakeUp | RxModes.LazyConfig)] + public void ConvertFromValue_IntegerValue_ReturnsRxModes(int raw, RxModes expected) + { + // Arrange + var sut = new FlagsMemberValueConverter(); + + // Act + var result = sut.ConvertFromValue(new IntegerValue(raw)); + + // Assert + result.Should().Be(expected); + } +} diff --git a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Links/GetLinksFlagsTests.cs b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Links/GetLinksFlagsTests.cs deleted file mode 100644 index 68e4815..0000000 --- a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Links/GetLinksFlagsTests.cs +++ /dev/null @@ -1,20 +0,0 @@ -using CreativeCoders.HomeMatic.XmlRpc.Links; -using AwesomeAssertions; - -namespace CreativeCoders.HomeMatic.XmlRpc.Tests.Links; - -public class GetLinksFlagsTests -{ - [Fact] - public void BitwiseOr_AllFlags_ProducesBitmaskSevenAndContainsEachFlag() - { - // Arrange & Act - var combined = GetLinksFlags.Group | GetLinksFlags.SenderParamSet | GetLinksFlags.ReceiverParamSet; - - // Assert - ((int) combined).Should().Be(7); - combined.HasFlag(GetLinksFlags.Group).Should().BeTrue(); - combined.HasFlag(GetLinksFlags.SenderParamSet).Should().BeTrue(); - combined.HasFlag(GetLinksFlags.ReceiverParamSet).Should().BeTrue(); - } -} diff --git a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Links/LinkFlagsTests.cs b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Links/LinkFlagsTests.cs deleted file mode 100644 index 29a1229..0000000 --- a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Links/LinkFlagsTests.cs +++ /dev/null @@ -1,19 +0,0 @@ -using CreativeCoders.HomeMatic.XmlRpc.Links; -using AwesomeAssertions; - -namespace CreativeCoders.HomeMatic.XmlRpc.Tests.Links; - -public class LinkFlagsTests -{ - [Fact] - public void BitwiseOr_SenderAndReceiverBroken_ReportsBothFlagsAndBitmaskThree() - { - // Arrange & Act - var flags = LinkFlags.SenderBroken | LinkFlags.ReceiverBroken; - - // Assert - flags.HasFlag(LinkFlags.SenderBroken).Should().BeTrue(); - flags.HasFlag(LinkFlags.ReceiverBroken).Should().BeTrue(); - ((int) flags).Should().Be(3); - } -} diff --git a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Links/LinkTests.cs b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Links/LinkTests.cs deleted file mode 100644 index 8c0aed7..0000000 --- a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Links/LinkTests.cs +++ /dev/null @@ -1,38 +0,0 @@ -using CreativeCoders.HomeMatic.XmlRpc.Links; -using AwesomeAssertions; - -namespace CreativeCoders.HomeMatic.XmlRpc.Tests.Links; - -public class LinkTests -{ - [Fact] - public void Constructor_Default_InitialisesAllPropertiesToEmptyDefaults() - { - // Arrange & Act - var link = new Link(); - - // Assert - link.Sender.Should().BeEmpty(); - link.Receiver.Should().BeEmpty(); - link.Name.Should().BeEmpty(); - link.Description.Should().BeEmpty(); - link.Flags.Should().Be(LinkFlags.None); - link.SenderParamSet.Should().NotBeNull().And.BeEmpty(); - link.ReceiverParamSet.Should().NotBeNull().And.BeEmpty(); - } - - [Fact] - public void ParamSets_WhenAssigned_AreMutable() - { - // Arrange - var link = new Link(); - - // Act - link.SenderParamSet["TEMPERATURE"] = 21.5; - link.ReceiverParamSet["LEVEL"] = 0.7; - - // Assert - link.SenderParamSet.Should().ContainKey("TEMPERATURE").WhoseValue.Should().Be(21.5); - link.ReceiverParamSet.Should().ContainKey("LEVEL").WhoseValue.Should().Be(0.7); - } -} From 1c91b91b0b4926cdd94d2b5e747b56f6a7bcd1fb Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:41:47 +0200 Subject: [PATCH 13/16] feat(tests): add comprehensive link management tests and export configuration validation - Added `CcuClientLinkTests` and `CcuDeviceChannelLinkTests` for thorough validation of link operations. - Introduced `CompleteCcuDeviceBuildOptions` class to configure link inclusion during snapshot creation. - Enhanced `DeviceExporter` to include communication links when exporting devices. - Updated interfaces and entities to support link retrieval, export, and error handling. - Increased test coverage for edge cases and API interactions. --- .../CompleteCcuDeviceBuildOptions.cs | 24 ++ .../Devices/ICcuDeviceChannel.cs | 61 +++- .../Devices/ICompleteCcuDeviceChannel.cs | 16 +- .../ICcuClient.cs | 64 +++- .../ICompleteCcuDeviceBuilder.cs | 3 +- .../IMultiCcuClient.cs | 8 +- source/CreativeCoders.HomeMatic/CcuClient.cs | 75 +++- .../CreativeCoders.HomeMatic/CcuDeviceBase.cs | 10 +- .../CcuDeviceChannel.cs | 60 +++- .../CompleteCcuDeviceBuilder.cs | 14 +- .../CompleteCcuDeviceChannel.cs | 6 +- .../Exporting/ChannelExportData.cs | 9 + .../Exporting/DeviceExportOptions.cs | 31 ++ .../Exporting/DeviceExporter.cs | 17 +- .../Exporting/LinkExportData.cs | 34 ++ .../MultiCcuClient.cs | 10 +- .../ShowDetails/ShowDeviceDetailsCommand.cs | 100 ++++-- .../CcuClientLinkTests.cs | 279 +++++++++++++++ .../CcuClientTests.cs | 2 +- .../CcuDeviceChannelLinkTests.cs | 327 ++++++++++++++++++ .../CompleteCcuDeviceBuilderTests.cs | 80 +++++ .../CompleteCcuDeviceChannelFakeBuilder.cs | 11 +- .../Exporting/DeviceExporterTests.cs | 154 +++++++++ 23 files changed, 1340 insertions(+), 55 deletions(-) create mode 100644 source/CreativeCoders.HomeMatic.Core/CompleteCcuDeviceBuildOptions.cs create mode 100644 source/CreativeCoders.HomeMatic/Exporting/LinkExportData.cs create mode 100644 tests/CreativeCoders.HomeMatic.Tests/CcuClientLinkTests.cs create mode 100644 tests/CreativeCoders.HomeMatic.Tests/CcuDeviceChannelLinkTests.cs diff --git a/source/CreativeCoders.HomeMatic.Core/CompleteCcuDeviceBuildOptions.cs b/source/CreativeCoders.HomeMatic.Core/CompleteCcuDeviceBuildOptions.cs new file mode 100644 index 0000000..055c5f0 --- /dev/null +++ b/source/CreativeCoders.HomeMatic.Core/CompleteCcuDeviceBuildOptions.cs @@ -0,0 +1,24 @@ +using CreativeCoders.HomeMatic.XmlRpc.Links; +using JetBrains.Annotations; + +namespace CreativeCoders.HomeMatic.Core; + +/// +/// Optional configuration for building an snapshot. +/// +[PublicAPI] +public class CompleteCcuDeviceBuildOptions +{ + /// + /// Gets or sets a value indicating whether the communication links of each channel are fetched + /// from the CCU and stored in the snapshot. + /// + /// to include links; otherwise, . Default is . + public bool IncludeLinks { get; set; } + + /// + /// Gets or sets the flags forwarded to getLinks when is enabled. + /// + /// The value. Default is . + public GetLinksFlags LinksFlags { get; set; } = GetLinksFlags.None; +} diff --git a/source/CreativeCoders.HomeMatic.Core/Devices/ICcuDeviceChannel.cs b/source/CreativeCoders.HomeMatic.Core/Devices/ICcuDeviceChannel.cs index 98c95dd..26e8b24 100644 --- a/source/CreativeCoders.HomeMatic.Core/Devices/ICcuDeviceChannel.cs +++ b/source/CreativeCoders.HomeMatic.Core/Devices/ICcuDeviceChannel.cs @@ -1,6 +1,65 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using CreativeCoders.HomeMatic.XmlRpc.Links; + namespace CreativeCoders.HomeMatic.Core.Devices; /// /// Represents a single channel of a HomeMatic device. /// -public interface ICcuDeviceChannel : ICcuDeviceBase, ICcuDeviceChannelData; +public interface ICcuDeviceChannel : ICcuDeviceBase, ICcuDeviceChannelData +{ + /// + /// Asynchronously retrieves all communication links assigned to this channel. + /// + /// A bitwise combination of values controlling the level of detail. + /// A task that yields a collection of structures describing each link. + Task> GetLinksAsync(GetLinksFlags flags = GetLinksFlags.None); + + /// + /// Asynchronously retrieves the addresses of all communication partners of this channel. + /// + /// A task that yields the peer addresses. + Task> GetLinkPeersAsync(); + + /// + /// Asynchronously creates a communication link from this channel to the specified receiver. + /// + /// The address of the receiver of the link. + /// An optional name for the link. + /// An optional description for the link. + /// A task that completes when the link has been created. + Task AddLinkToAsync(string receiverAddress, string name = "", string description = ""); + + /// + /// Asynchronously removes the communication link from this channel to the specified receiver. + /// + /// The address of the receiver of the link. + /// A task that completes when the link has been removed. + Task RemoveLinkToAsync(string receiverAddress); + + /// + /// Asynchronously updates the descriptive texts of an existing communication link from this channel. + /// + /// The address of the receiver of the link. + /// The new name of the link. + /// The new description of the link. + /// A task that completes when the link has been updated. + Task SetLinkInfoAsync(string receiverAddress, string name, string description); + + /// + /// Asynchronously retrieves the descriptive information of an existing communication link from this channel. + /// + /// The address of the receiver of the link. + /// A task that yields a instance. + Task GetLinkInfoAsync(string receiverAddress); + + /// + /// Asynchronously activates a link parameter set so that this channel behaves as if it had been + /// triggered directly by the specified communication partner. + /// + /// The address of the communication partner whose link parameter set is activated. + /// to activate the parameter set for a long key press; otherwise . + /// A task that completes when the parameter set has been activated. + Task ActivateLinkParamsetAsync(string peerAddress, bool longPress); +} diff --git a/source/CreativeCoders.HomeMatic.Core/Devices/ICompleteCcuDeviceChannel.cs b/source/CreativeCoders.HomeMatic.Core/Devices/ICompleteCcuDeviceChannel.cs index 685703c..11dc8e7 100644 --- a/source/CreativeCoders.HomeMatic.Core/Devices/ICompleteCcuDeviceChannel.cs +++ b/source/CreativeCoders.HomeMatic.Core/Devices/ICompleteCcuDeviceChannel.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using CreativeCoders.HomeMatic.XmlRpc.Links; namespace CreativeCoders.HomeMatic.Core.Devices; @@ -8,14 +9,23 @@ namespace CreativeCoders.HomeMatic.Core.Devices; public interface ICompleteCcuDeviceChannel { /// - /// Gets the channel-level data. + /// Gets the channel and its operations. /// - /// The for this channel. - ICcuDeviceChannelData ChannelData { get; } + /// The for this channel. + ICcuDeviceChannel ChannelData { get; } /// /// Gets the parameter-set values and descriptions for the channel. /// /// The enumerable of groups. IEnumerable ParamSetValues { get; } + + /// + /// Gets the communication links of the channel that were fetched during snapshot creation. + /// + /// + /// The collection of structures. Empty when the snapshot was built without + /// link fetching enabled. + /// + IEnumerable Links { get; } } diff --git a/source/CreativeCoders.HomeMatic.Core/ICcuClient.cs b/source/CreativeCoders.HomeMatic.Core/ICcuClient.cs index 3ea57e4..5ac5dbf 100644 --- a/source/CreativeCoders.HomeMatic.Core/ICcuClient.cs +++ b/source/CreativeCoders.HomeMatic.Core/ICcuClient.cs @@ -1,6 +1,8 @@ using System.Collections.Generic; using System.Threading.Tasks; using CreativeCoders.HomeMatic.Core.Devices; +using CreativeCoders.HomeMatic.XmlRpc; +using CreativeCoders.HomeMatic.XmlRpc.Links; namespace CreativeCoders.HomeMatic.Core; @@ -25,13 +27,71 @@ public interface ICcuClient /// /// Asynchronously retrieves all devices including their parameter descriptions. /// + /// Optional build options controlling whether links are fetched. /// A task that yields an enumerable of instances. - Task> GetCompleteDevicesAsync(); + Task> GetCompleteDevicesAsync( + CompleteCcuDeviceBuildOptions? buildOptions = null); /// /// Asynchronously retrieves a single device including its parameter descriptions. /// /// The device address. + /// Optional build options controlling whether links are fetched. /// A task that yields the matching . - Task GetCompleteDeviceAsync(string address); + Task GetCompleteDeviceAsync(string address, + CompleteCcuDeviceBuildOptions? buildOptions = null); + + /// + /// Asynchronously retrieves all communication links known to the CCU interface process of the + /// specified device kind. + /// + /// The device kind whose interface process is queried. + /// A bitwise combination of values controlling the level of detail. + /// A task that yields a collection of structures describing each link. + Task> GetAllLinksAsync(CcuDeviceKind kind = CcuDeviceKind.HomeMatic, + GetLinksFlags flags = GetLinksFlags.None); + + /// + /// Asynchronously creates a communication link between two logical channels or devices. + /// + /// The address of the sender of the link. + /// The address of the receiver of the link. + /// An optional name for the link. + /// An optional description for the link. + /// The device kind whose interface process performs the operation. + /// A task that completes when the link has been created. + Task AddLinkAsync(string senderAddress, string receiverAddress, string name = "", + string description = "", CcuDeviceKind kind = CcuDeviceKind.HomeMatic); + + /// + /// Asynchronously removes the communication link between two logical channels or devices. + /// + /// The address of the sender of the link. + /// The address of the receiver of the link. + /// The device kind whose interface process performs the operation. + /// A task that completes when the link has been removed. + Task RemoveLinkAsync(string senderAddress, string receiverAddress, + CcuDeviceKind kind = CcuDeviceKind.HomeMatic); + + /// + /// Asynchronously updates the descriptive texts of an existing communication link. + /// + /// The address of the sender of the link. + /// The address of the receiver of the link. + /// The new name of the link. + /// The new description of the link. + /// The device kind whose interface process performs the operation. + /// A task that completes when the link has been updated. + Task SetLinkInfoAsync(string senderAddress, string receiverAddress, string name, + string description, CcuDeviceKind kind = CcuDeviceKind.HomeMatic); + + /// + /// Asynchronously retrieves the descriptive information of an existing communication link. + /// + /// The address of the sender of the link. + /// The address of the receiver of the link. + /// The device kind whose interface process performs the operation. + /// A task that yields a instance. + Task GetLinkInfoAsync(string senderAddress, string receiverAddress, + CcuDeviceKind kind = CcuDeviceKind.HomeMatic); } diff --git a/source/CreativeCoders.HomeMatic.Core/ICompleteCcuDeviceBuilder.cs b/source/CreativeCoders.HomeMatic.Core/ICompleteCcuDeviceBuilder.cs index 9fca400..0545eac 100644 --- a/source/CreativeCoders.HomeMatic.Core/ICompleteCcuDeviceBuilder.cs +++ b/source/CreativeCoders.HomeMatic.Core/ICompleteCcuDeviceBuilder.cs @@ -12,6 +12,7 @@ public interface ICompleteCcuDeviceBuilder /// Asynchronously builds a complete device representation for the specified device. /// /// The base device to augment with parameter descriptions. + /// Optional build options controlling whether links are fetched. /// A task that yields the completed . - Task BuildAsync(ICcuDevice device); + Task BuildAsync(ICcuDevice device, CompleteCcuDeviceBuildOptions? options = null); } diff --git a/source/CreativeCoders.HomeMatic.Core/IMultiCcuClient.cs b/source/CreativeCoders.HomeMatic.Core/IMultiCcuClient.cs index f211367..8832986 100644 --- a/source/CreativeCoders.HomeMatic.Core/IMultiCcuClient.cs +++ b/source/CreativeCoders.HomeMatic.Core/IMultiCcuClient.cs @@ -27,13 +27,17 @@ public interface IMultiCcuClient /// /// Asynchronously retrieves all devices including their parameter descriptions from every configured CCU. /// + /// Optional build options controlling whether links are fetched. /// A task that yields an enumerable of instances. - Task> GetCompleteDevicesAsync(); + Task> GetCompleteDevicesAsync( + CompleteCcuDeviceBuildOptions? buildOptions = null); /// /// Asynchronously retrieves a single device including its parameter descriptions across all configured CCUs. /// /// The device address. + /// Optional build options controlling whether links are fetched. /// A task that yields the matching . - Task GetCompleteDeviceAsync(string address); + Task GetCompleteDeviceAsync(string address, + CompleteCcuDeviceBuildOptions? buildOptions = null); } diff --git a/source/CreativeCoders.HomeMatic/CcuClient.cs b/source/CreativeCoders.HomeMatic/CcuClient.cs index ba478b1..62750c5 100644 --- a/source/CreativeCoders.HomeMatic/CcuClient.cs +++ b/source/CreativeCoders.HomeMatic/CcuClient.cs @@ -1,8 +1,11 @@ +using CreativeCoders.Core; using CreativeCoders.Core.Collections; using CreativeCoders.HomeMatic.Core; using CreativeCoders.HomeMatic.Core.Devices; using CreativeCoders.HomeMatic.JsonRpc; using CreativeCoders.HomeMatic.XmlRpc; +using CreativeCoders.HomeMatic.XmlRpc.Client; +using CreativeCoders.HomeMatic.XmlRpc.Links; namespace CreativeCoders.HomeMatic; @@ -70,23 +73,87 @@ public async Task GetDeviceAsync(string address) } /// - public async Task> GetCompleteDevicesAsync() + public async Task> GetCompleteDevicesAsync( + CompleteCcuDeviceBuildOptions? buildOptions = null) { var completeDevices = new List(); foreach (var ccuDevice in await GetDevicesAsync().ConfigureAwait(false)) { - completeDevices.Add(await completeCcuDeviceBuilder.BuildAsync(ccuDevice).ConfigureAwait(false)); + completeDevices.Add(await completeCcuDeviceBuilder.BuildAsync(ccuDevice, buildOptions).ConfigureAwait(false)); } return [..completeDevices]; } /// - public async Task GetCompleteDeviceAsync(string address) + public async Task GetCompleteDeviceAsync(string address, + CompleteCcuDeviceBuildOptions? buildOptions = null) { var ccuDevice = await GetDeviceAsync(address).ConfigureAwait(false); - return await completeCcuDeviceBuilder.BuildAsync(ccuDevice).ConfigureAwait(false); + return await completeCcuDeviceBuilder.BuildAsync(ccuDevice, buildOptions).ConfigureAwait(false); + } + + /// + public Task> GetAllLinksAsync(CcuDeviceKind kind = CcuDeviceKind.HomeMatic, + GetLinksFlags flags = GetLinksFlags.None) + { + return GetApi(kind).GetLinksAsync(string.Empty, flags); + } + + /// + public Task AddLinkAsync(string senderAddress, string receiverAddress, string name = "", + string description = "", CcuDeviceKind kind = CcuDeviceKind.HomeMatic) + { + Ensure.IsNotNullOrWhitespace(senderAddress); + Ensure.IsNotNullOrWhitespace(receiverAddress); + Ensure.NotNull(name); + Ensure.NotNull(description); + + return GetApi(kind).AddLinkAsync(senderAddress, receiverAddress, name, description); + } + + /// + public Task RemoveLinkAsync(string senderAddress, string receiverAddress, + CcuDeviceKind kind = CcuDeviceKind.HomeMatic) + { + Ensure.IsNotNullOrWhitespace(senderAddress); + Ensure.IsNotNullOrWhitespace(receiverAddress); + + return GetApi(kind).RemoveLinkAsync(senderAddress, receiverAddress); + } + + /// + public Task SetLinkInfoAsync(string senderAddress, string receiverAddress, string name, + string description, CcuDeviceKind kind = CcuDeviceKind.HomeMatic) + { + Ensure.IsNotNullOrWhitespace(senderAddress); + Ensure.IsNotNullOrWhitespace(receiverAddress); + Ensure.NotNull(name); + Ensure.NotNull(description); + + return GetApi(kind).SetLinkInfoAsync(senderAddress, receiverAddress, name, description); + } + + /// + public Task GetLinkInfoAsync(string senderAddress, string receiverAddress, + CcuDeviceKind kind = CcuDeviceKind.HomeMatic) + { + Ensure.IsNotNullOrWhitespace(senderAddress); + Ensure.IsNotNullOrWhitespace(receiverAddress); + + return GetApi(kind).GetLinkInfoAsync(senderAddress, receiverAddress); + } + + private IHomeMaticXmlRpcApi GetApi(CcuDeviceKind kind) + { + if (!xmlRpcApis.TryGetValue(kind, out var connection)) + { + throw new KeyNotFoundException( + $"No XML-RPC API connection configured for device kind '{kind}'."); + } + + return connection.Api; } } diff --git a/source/CreativeCoders.HomeMatic/CcuDeviceBase.cs b/source/CreativeCoders.HomeMatic/CcuDeviceBase.cs index 21e2dcd..e6ae622 100644 --- a/source/CreativeCoders.HomeMatic/CcuDeviceBase.cs +++ b/source/CreativeCoders.HomeMatic/CcuDeviceBase.cs @@ -10,6 +10,12 @@ namespace CreativeCoders.HomeMatic; /// The XML-RPC API used to query parameter-set values and descriptions from the CCU. public abstract class CcuDeviceBase(IHomeMaticXmlRpcApi api) : ICcuDeviceBase { + /// + /// Gets the XML-RPC API used to talk to the CCU on behalf of this device or channel. + /// + /// The instance supplied via the constructor. + protected IHomeMaticXmlRpcApi Api { get; } = api; + /// public required CcuDeviceUri Uri { get; init; } @@ -34,7 +40,7 @@ public abstract class CcuDeviceBase(IHomeMaticXmlRpcApi api) : ICcuDeviceBase /// public async Task> GetParamSetValuesAsync(string paramSetKey) { - var paramSets = await api.GetParamSetAsync(Uri.Address, paramSetKey).ConfigureAwait(false); + var paramSets = await Api.GetParamSetAsync(Uri.Address, paramSetKey).ConfigureAwait(false); return paramSets.Select(x => new ParamSetValue { @@ -47,7 +53,7 @@ public async Task> GetParamSetValuesAsync(string para public async Task GetParamSetDescriptionsAsync(string paramSetKey) { var paramSetDescriptions = - await api.GetParameterDescriptionAsync(Uri.Address, paramSetKey).ConfigureAwait(false); + await Api.GetParameterDescriptionAsync(Uri.Address, paramSetKey).ConfigureAwait(false); return new CcuParameterDescriptions { diff --git a/source/CreativeCoders.HomeMatic/CcuDeviceChannel.cs b/source/CreativeCoders.HomeMatic/CcuDeviceChannel.cs index 364486f..24469c3 100644 --- a/source/CreativeCoders.HomeMatic/CcuDeviceChannel.cs +++ b/source/CreativeCoders.HomeMatic/CcuDeviceChannel.cs @@ -1,13 +1,15 @@ +using CreativeCoders.Core; using CreativeCoders.HomeMatic.Core.Devices; using CreativeCoders.HomeMatic.XmlRpc.Client; using CreativeCoders.HomeMatic.XmlRpc.Devices; +using CreativeCoders.HomeMatic.XmlRpc.Links; namespace CreativeCoders.HomeMatic; /// /// Represents a single channel of a HomeMatic device. /// -/// The XML-RPC API used to query parameter-set values and descriptions from the CCU. +/// The XML-RPC API used to query parameter-set values and descriptions and to manage communication links. public class CcuDeviceChannel(IHomeMaticXmlRpcApi api) : CcuDeviceBase(api), ICcuDeviceChannel { /// @@ -18,4 +20,60 @@ public class CcuDeviceChannel(IHomeMaticXmlRpcApi api) : CcuDeviceBase(api), ICc /// public required ChannelDirection ChannelDirection { get; init; } + + /// + public Task> GetLinksAsync(GetLinksFlags flags = GetLinksFlags.None) + { + return Api.GetLinksAsync(Uri.Address, flags); + } + + /// + public Task> GetLinkPeersAsync() + { + return Api.GetLinkPeersAsync(Uri.Address); + } + + /// + public Task AddLinkToAsync(string receiverAddress, string name = "", string description = "") + { + Ensure.IsNotNullOrWhitespace(receiverAddress); + Ensure.NotNull(name); + Ensure.NotNull(description); + + return Api.AddLinkAsync(Uri.Address, receiverAddress, name, description); + } + + /// + public Task RemoveLinkToAsync(string receiverAddress) + { + Ensure.IsNotNullOrWhitespace(receiverAddress); + + return Api.RemoveLinkAsync(Uri.Address, receiverAddress); + } + + /// + public Task SetLinkInfoAsync(string receiverAddress, string name, string description) + { + Ensure.IsNotNullOrWhitespace(receiverAddress); + Ensure.NotNull(name); + Ensure.NotNull(description); + + return Api.SetLinkInfoAsync(Uri.Address, receiverAddress, name, description); + } + + /// + public Task GetLinkInfoAsync(string receiverAddress) + { + Ensure.IsNotNullOrWhitespace(receiverAddress); + + return Api.GetLinkInfoAsync(Uri.Address, receiverAddress); + } + + /// + public Task ActivateLinkParamsetAsync(string peerAddress, bool longPress) + { + Ensure.IsNotNullOrWhitespace(peerAddress); + + return Api.ActivateLinkParamsetAsync(Uri.Address, peerAddress, longPress); + } } diff --git a/source/CreativeCoders.HomeMatic/CompleteCcuDeviceBuilder.cs b/source/CreativeCoders.HomeMatic/CompleteCcuDeviceBuilder.cs index 06b22a8..5a61233 100644 --- a/source/CreativeCoders.HomeMatic/CompleteCcuDeviceBuilder.cs +++ b/source/CreativeCoders.HomeMatic/CompleteCcuDeviceBuilder.cs @@ -12,9 +12,9 @@ namespace CreativeCoders.HomeMatic; public class CompleteCcuDeviceBuilder : ICompleteCcuDeviceBuilder { /// - public async Task BuildAsync(ICcuDevice device) + public async Task BuildAsync(ICcuDevice device, CompleteCcuDeviceBuildOptions? options = null) { - var channels = await GetChannelsAsync(device).ConfigureAwait(false); + var channels = await GetChannelsAsync(device, options).ConfigureAwait(false); var completeDevice = new CompleteCcuDevice { @@ -26,16 +26,22 @@ public async Task BuildAsync(ICcuDevice device) return completeDevice; } - private static async Task> GetChannelsAsync(ICcuDevice device) + private static async Task> GetChannelsAsync(ICcuDevice device, + CompleteCcuDeviceBuildOptions? options) { var channels = new List(); foreach (var ccuDeviceChannel in device.Channels) { + var links = options?.IncludeLinks == true + ? (await ccuDeviceChannel.GetLinksAsync(options.LinksFlags).ConfigureAwait(false)).ToArray() + : []; + var completeChannel = new CompleteCcuDeviceChannel { ChannelData = ccuDeviceChannel, - ParamSetValues = await GetParamSetValuesAsync(ccuDeviceChannel).ConfigureAwait(false) + ParamSetValues = await GetParamSetValuesAsync(ccuDeviceChannel).ConfigureAwait(false), + Links = links }; channels.Add(completeChannel); diff --git a/source/CreativeCoders.HomeMatic/CompleteCcuDeviceChannel.cs b/source/CreativeCoders.HomeMatic/CompleteCcuDeviceChannel.cs index cb5b2c6..1bcc2b2 100644 --- a/source/CreativeCoders.HomeMatic/CompleteCcuDeviceChannel.cs +++ b/source/CreativeCoders.HomeMatic/CompleteCcuDeviceChannel.cs @@ -1,4 +1,5 @@ using CreativeCoders.HomeMatic.Core.Devices; +using CreativeCoders.HomeMatic.XmlRpc.Links; namespace CreativeCoders.HomeMatic; @@ -9,8 +10,11 @@ namespace CreativeCoders.HomeMatic; public class CompleteCcuDeviceChannel : ICompleteCcuDeviceChannel { /// - public required ICcuDeviceChannelData ChannelData { get; init; } + public required ICcuDeviceChannel ChannelData { get; init; } /// public required IEnumerable ParamSetValues { get; init; } + + /// + public IEnumerable Links { get; init; } = []; } diff --git a/source/CreativeCoders.HomeMatic/Exporting/ChannelExportData.cs b/source/CreativeCoders.HomeMatic/Exporting/ChannelExportData.cs index d18ce64..649c480 100644 --- a/source/CreativeCoders.HomeMatic/Exporting/ChannelExportData.cs +++ b/source/CreativeCoders.HomeMatic/Exporting/ChannelExportData.cs @@ -34,4 +34,13 @@ public class ChannelExportData /// /// The enumerable of entries. public required IEnumerable ParamSetValues { get; init; } + + /// + /// Gets the communication links of the channel that passed the export filter. + /// + /// + /// The enumerable of entries, or when link + /// export is disabled. values are omitted from the JSON output. + /// + public IEnumerable? Links { get; init; } } diff --git a/source/CreativeCoders.HomeMatic/Exporting/DeviceExportOptions.cs b/source/CreativeCoders.HomeMatic/Exporting/DeviceExportOptions.cs index d277603..d17267d 100644 --- a/source/CreativeCoders.HomeMatic/Exporting/DeviceExportOptions.cs +++ b/source/CreativeCoders.HomeMatic/Exporting/DeviceExportOptions.cs @@ -1,3 +1,5 @@ +using CreativeCoders.HomeMatic.Core; +using CreativeCoders.HomeMatic.XmlRpc.Links; using JetBrains.Annotations; namespace CreativeCoders.HomeMatic.Exporting; @@ -22,6 +24,22 @@ public class DeviceExportOptions /// public bool WriteIndented { get; set; } = true; + /// + /// Gets or sets a value indicating whether the communication links of each channel are emitted + /// to the export. Links must already be present in the snapshot — see + /// . + /// + /// to emit links; otherwise, . Default is . + public bool IncludeLinks { get; set; } + + /// + /// Gets or sets the flags that should be used when fetching the links during snapshot creation. + /// This value is intended to be forwarded to + /// by callers that build snapshots specifically for an export. + /// + /// The value. Default is . + public GetLinksFlags LinksFlags { get; set; } = GetLinksFlags.None; + /// /// Determines whether a ParamSet key is allowed based on the . /// @@ -51,4 +69,17 @@ public bool IsParamValueNameAllowed(string paramValueName) return ParamValueNameWhitelist.Contains(paramValueName, StringComparer.OrdinalIgnoreCase); } + + /// + /// Builds a matching this export configuration. + /// + /// A that includes links iff is set. + public CompleteCcuDeviceBuildOptions ToBuildOptions() + { + return new CompleteCcuDeviceBuildOptions + { + IncludeLinks = IncludeLinks, + LinksFlags = LinksFlags + }; + } } diff --git a/source/CreativeCoders.HomeMatic/Exporting/DeviceExporter.cs b/source/CreativeCoders.HomeMatic/Exporting/DeviceExporter.cs index 881970c..4ff5800 100644 --- a/source/CreativeCoders.HomeMatic/Exporting/DeviceExporter.cs +++ b/source/CreativeCoders.HomeMatic/Exporting/DeviceExporter.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using CreativeCoders.HomeMatic.Core.Devices; +using CreativeCoders.HomeMatic.XmlRpc.Links; namespace CreativeCoders.HomeMatic.Exporting; @@ -55,10 +56,24 @@ private static ChannelExportData BuildChannelExportData( DeviceType = channel.ChannelData.DeviceType, Index = channel.ChannelData.Index, ParamSets = channel.ChannelData.ParamSets, - ParamSetValues = BuildParamSetExportData(channel.ParamSetValues, options) + ParamSetValues = BuildParamSetExportData(channel.ParamSetValues, options), + Links = options?.IncludeLinks == true ? BuildLinkExportData(channel.Links) : null }; } + private static IEnumerable BuildLinkExportData(IEnumerable links) + { + return links + .Select(link => new LinkExportData + { + Sender = link.Sender, + Receiver = link.Receiver, + Name = link.Name, + Description = link.Description + }) + .ToList(); + } + private static ParamSetExportData[] BuildParamSetExportData( IEnumerable paramSetValues, DeviceExportOptions? options) diff --git a/source/CreativeCoders.HomeMatic/Exporting/LinkExportData.cs b/source/CreativeCoders.HomeMatic/Exporting/LinkExportData.cs new file mode 100644 index 0000000..50893a8 --- /dev/null +++ b/source/CreativeCoders.HomeMatic/Exporting/LinkExportData.cs @@ -0,0 +1,34 @@ +using JetBrains.Annotations; + +namespace CreativeCoders.HomeMatic.Exporting; + +/// +/// Represents a single communication link as it appears in a device export. +/// +[PublicAPI] +public class LinkExportData +{ + /// + /// Gets the address of the sender of the link. + /// + /// The sender channel or device address. + public required string Sender { get; init; } + + /// + /// Gets the address of the receiver of the link. + /// + /// The receiver channel or device address. + public required string Receiver { get; init; } + + /// + /// Gets the human-readable name of the link. + /// + /// The link name; an empty string if not set on the CCU. + public required string Name { get; init; } + + /// + /// Gets the textual description of the link. + /// + /// The link description; an empty string if not set on the CCU. + public required string Description { get; init; } +} diff --git a/source/CreativeCoders.HomeMatic/MultiCcuClient.cs b/source/CreativeCoders.HomeMatic/MultiCcuClient.cs index 48de712..eb70a08 100644 --- a/source/CreativeCoders.HomeMatic/MultiCcuClient.cs +++ b/source/CreativeCoders.HomeMatic/MultiCcuClient.cs @@ -42,9 +42,10 @@ public Task GetDeviceAsync(string address) } /// - public async Task> GetCompleteDevicesAsync() + public async Task> GetCompleteDevicesAsync( + CompleteCcuDeviceBuildOptions? buildOptions = null) { - var results = await GetDataFromClientsAsync(x => x.GetCompleteDevicesAsync()).ConfigureAwait(false); + var results = await GetDataFromClientsAsync(x => x.GetCompleteDevicesAsync(buildOptions)).ConfigureAwait(false); RegisterRoutes(results.SelectMany(pair => pair.Items.Select(item => (item.DeviceData.Uri.Address, pair.Client)))); @@ -53,12 +54,13 @@ public async Task> GetCompleteDevicesAsync() } /// - public Task GetCompleteDeviceAsync(string address) + public Task GetCompleteDeviceAsync(string address, + CompleteCcuDeviceBuildOptions? buildOptions = null) { Ensure.IsNotNullOrWhitespace(address); return InvokeWithRoutingAsync(address, - (client, deviceAddress) => client.GetCompleteDeviceAsync(deviceAddress)); + (client, deviceAddress) => client.GetCompleteDeviceAsync(deviceAddress, buildOptions)); } // Generic helper that routes a per-device call through the routing table. Can be reused by future diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/ShowDetails/ShowDeviceDetailsCommand.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/ShowDetails/ShowDeviceDetailsCommand.cs index 554fff0..adeae13 100644 --- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/ShowDetails/ShowDeviceDetailsCommand.cs +++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/ShowDetails/ShowDeviceDetailsCommand.cs @@ -1,8 +1,7 @@ using CreativeCoders.Cli.Core; using CreativeCoders.Core; -using CreativeCoders.Core.Collections; using CreativeCoders.HomeMatic.Core; -using CreativeCoders.HomeMatic.Core.Devices; +using CreativeCoders.HomeMatic.Exporting; using CreativeCoders.SysConsole.Core; using JetBrains.Annotations; using Spectre.Console; @@ -11,64 +10,111 @@ namespace CreativeCoders.HomeMatic.Tools.Cli.Commands.Device.ShowDetails; [UsedImplicitly] [CliCommand([DeviceCommandGroup.Name, "details"], Description = "Show details for a device")] -public class ShowDeviceDetailsCommand(IAnsiConsole console, IMultiCcuClient multiCcuClient) +public class ShowDeviceDetailsCommand( + IAnsiConsole console, + IMultiCcuClient multiCcuClient, + IDeviceExporter deviceExporter) : ICliCommand { private readonly IMultiCcuClient _multiCcuClient = Ensure.NotNull(multiCcuClient); private readonly IAnsiConsole _console = Ensure.NotNull(console); + private readonly IDeviceExporter _deviceExporter = Ensure.NotNull(deviceExporter); + public async Task ExecuteAsync(ShowDeviceDetailsOptions options) { - var device = await _multiCcuClient.GetCompleteDeviceAsync(options.Address).ConfigureAwait(false); + var exportOptions = new DeviceExportOptions + { + IncludeLinks = true + }; + + var device = await _multiCcuClient + .GetCompleteDeviceAsync(options.Address, exportOptions.ToBuildOptions()) + .ConfigureAwait(false); + + var exportData = _deviceExporter.BuildExportData(device, exportOptions); _console.WriteLine($"Show device details for '{options.Address}'"); _console.WriteLine(); - PrintDevice(device); + PrintDevice(exportData); return CommandResult.Success; } - private void PrintDevice(ICompleteCcuDevice device) + private void PrintDevice(DeviceExportData device) { - _console.MarkupLine($"Name: [bold teal]{device.DeviceData.Name}[/]"); - _console.MarkupLine($"Address: [bold]{device.DeviceData.Uri.Address}[/]"); - _console.MarkupLine($"Ccu: [bold yellow]{device.DeviceData.Uri.HostDisplayName}[/]"); - _console.MarkupLine($"Type: {device.DeviceData.DeviceType}"); + _console.MarkupLine($"Name: [bold teal]{Markup.Escape(device.Name)}[/]"); + _console.MarkupLine($"Address: [bold]{Markup.Escape(device.Address)}[/]"); + _console.MarkupLine($"Ccu: [bold yellow]{Markup.Escape(device.Ccu)}[/]"); + _console.MarkupLine($"Type: {Markup.Escape(device.DeviceType)}"); + _console.MarkupLine($"Firmware: {Markup.Escape(device.FirmwareVersion)}"); + _console.MarkupLine($"ParamSet keys: {Markup.Escape(string.Join(", ", device.ParamSetKeys))}"); _console.WriteLine(); + _console.WriteLine("Device ParamSets:"); + PrintParamSets(device.ParamSetValues, " "); - _console.WriteLine(" Device ParamSets:"); - + _console.WriteLine(); _console.WriteLine("Channels:"); - device.Channels.ForEach(PrintChannel); - - PrintParamSets(device.ParamSetValues, " "); + foreach (var channel in device.Channels) + { + PrintChannel(channel); + } } - private void PrintChannel(ICompleteCcuDeviceChannel channel) + private void PrintChannel(ChannelExportData channel) { - _console.WriteLine($" - Index: {channel.ChannelData.Index}"); - _console.WriteLine($" Address: {channel.ChannelData.Uri.Address}"); - _console.WriteLine($" Type: {channel.ChannelData.DeviceType}"); + _console.WriteLine($" - Index: {channel.Index}"); + _console.WriteLine($" Address: {channel.Address}"); + _console.WriteLine($" Type: {channel.DeviceType}"); + _console.WriteLine($" ParamSet keys: {string.Join(", ", channel.ParamSets)}"); + _console.WriteLine(" Channel ParamSets:"); + PrintParamSets(channel.ParamSetValues, " "); + + if (channel.Links is not null) + { + PrintLinks(channel.Links, " "); + } + } + + private void PrintParamSets(IEnumerable paramSets, string indent) + { + foreach (var paramSet in paramSets) + { + _console.WriteLine($"{indent}- ParamSet: {paramSet.ParamSetKey}"); - PrintParamSets(channel.ParamSetValues, " "); + foreach (var value in paramSet.Values) + { + var label = value.Name is not null && !string.Equals(value.Name, value.Key, StringComparison.Ordinal) + ? $"{value.Key} ({value.Name})" + : value.Key; + + _console.WriteLine($"{indent} - {label} : {value.Value}"); + } + } } - private void PrintParamSets(IEnumerable paramSetValuesWithDescriptions, - string indent) + private void PrintLinks(IEnumerable links, string indent) { - foreach (var paramSet in paramSetValuesWithDescriptions) + var linkList = links.ToList(); + + if (linkList.Count == 0) { - var values = paramSet.ParamSetValues; + return; + } - _console.WriteLine($"{indent}- ParamSet: {paramSet.ParamSetKey}"); + _console.WriteLine($"{indent}Links:"); - _console.WriteLines(values.Select(x => $"{indent} - {x.ParamSetValue.Name} : {x.ParamSetValue.Value}") - .ToArray()); + foreach (var link in linkList) + { + _console.WriteLine($"{indent} - Sender: {link.Sender}"); + _console.WriteLine($"{indent} Receiver: {link.Receiver}"); + _console.WriteLine($"{indent} Name: {link.Name}"); + _console.WriteLine($"{indent} Description: {link.Description}"); } } } diff --git a/tests/CreativeCoders.HomeMatic.Tests/CcuClientLinkTests.cs b/tests/CreativeCoders.HomeMatic.Tests/CcuClientLinkTests.cs new file mode 100644 index 0000000..b8acaf0 --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.Tests/CcuClientLinkTests.cs @@ -0,0 +1,279 @@ +using CreativeCoders.HomeMatic.Core; +using CreativeCoders.HomeMatic.JsonRpc; +using CreativeCoders.HomeMatic.XmlRpc; +using CreativeCoders.HomeMatic.XmlRpc.Client; +using CreativeCoders.HomeMatic.XmlRpc.Links; +using FakeItEasy; +using AwesomeAssertions; + +namespace CreativeCoders.HomeMatic.Tests; + +public class CcuClientLinkTests +{ + private const string SenderAddress = "BIDCOS:1"; + private const string ReceiverAddress = "BIDCOS:2"; + + [Fact] + public async Task GetAllLinksAsync_DefaultKind_CallsHomeMaticApiWithEmptyAddress() + { + // Arrange + var homeMaticApi = A.Fake(); + var homeMaticIpApi = A.Fake(); + var expected = new[] { new Link { Sender = SenderAddress, Receiver = ReceiverAddress } }; + A.CallTo(() => homeMaticApi.GetLinksAsync(string.Empty, (int)GetLinksFlags.None)) + .Returns(Task.FromResult>(expected)); + + var client = CreateClient(homeMaticApi, homeMaticIpApi); + + // Act + var result = await client.GetAllLinksAsync(); + + // Assert + result.Should().BeEquivalentTo(expected); + A.CallTo(() => homeMaticIpApi.GetLinksAsync(A._, A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task GetAllLinksAsync_WithSpecificKind_CallsCorrespondingApi() + { + // Arrange + var homeMaticApi = A.Fake(); + var homeMaticIpApi = A.Fake(); + const GetLinksFlags flags = GetLinksFlags.SenderParamSet; + A.CallTo(() => homeMaticIpApi.GetLinksAsync(string.Empty, (int)flags)) + .Returns(Task.FromResult>([])); + + var client = CreateClient(homeMaticApi, homeMaticIpApi); + + // Act + await client.GetAllLinksAsync(CcuDeviceKind.HomeMaticIp, flags); + + // Assert + A.CallTo(() => homeMaticIpApi.GetLinksAsync(string.Empty, (int)flags)) + .MustHaveHappenedOnceExactly(); + A.CallTo(() => homeMaticApi.GetLinksAsync(A._, A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task AddLinkAsync_DefaultKind_DelegatesToHomeMaticApi() + { + // Arrange + var homeMaticApi = A.Fake(); + var homeMaticIpApi = A.Fake(); + var client = CreateClient(homeMaticApi, homeMaticIpApi); + + // Act + await client.AddLinkAsync(SenderAddress, ReceiverAddress, "n", "d"); + + // Assert + A.CallTo(() => homeMaticApi.AddLinkAsync(SenderAddress, ReceiverAddress, "n", "d")) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task AddLinkAsync_WithIpKind_DelegatesToHomeMaticIpApi() + { + // Arrange + var homeMaticApi = A.Fake(); + var homeMaticIpApi = A.Fake(); + var client = CreateClient(homeMaticApi, homeMaticIpApi); + + // Act + await client.AddLinkAsync(SenderAddress, ReceiverAddress, kind: CcuDeviceKind.HomeMaticIp); + + // Assert + A.CallTo(() => homeMaticIpApi.AddLinkAsync(SenderAddress, ReceiverAddress, string.Empty, string.Empty)) + .MustHaveHappenedOnceExactly(); + A.CallTo(() => homeMaticApi.AddLinkAsync(A._, A._, A._, A._)) + .MustNotHaveHappened(); + } + + [Theory] + [InlineData(null, "BIDCOS:2")] + [InlineData("", "BIDCOS:2")] + [InlineData(" ", "BIDCOS:2")] + [InlineData("BIDCOS:1", null)] + [InlineData("BIDCOS:1", "")] + [InlineData("BIDCOS:1", " ")] + public async Task AddLinkAsync_NullOrWhitespaceAddress_ThrowsAndDoesNotCallApi(string? sender, + string? receiver) + { + // Arrange + var homeMaticApi = A.Fake(); + var homeMaticIpApi = A.Fake(); + var client = CreateClient(homeMaticApi, homeMaticIpApi); + + // Act + var act = () => client.AddLinkAsync(sender!, receiver!); + + // Assert + await act.Should().ThrowAsync(); + A.CallTo(homeMaticApi).MustNotHaveHappened(); + A.CallTo(homeMaticIpApi).MustNotHaveHappened(); + } + + [Fact] + public async Task AddLinkAsync_NullName_ThrowsAndDoesNotCallApi() + { + // Arrange + var homeMaticApi = A.Fake(); + var homeMaticIpApi = A.Fake(); + var client = CreateClient(homeMaticApi, homeMaticIpApi); + + // Act + var act = () => client.AddLinkAsync(SenderAddress, ReceiverAddress, null!, "d"); + + // Assert + await act.Should().ThrowAsync(); + A.CallTo(homeMaticApi).MustNotHaveHappened(); + } + + [Fact] + public async Task RemoveLinkAsync_DelegatesToApiOfRequestedKind() + { + // Arrange + var homeMaticApi = A.Fake(); + var homeMaticIpApi = A.Fake(); + var client = CreateClient(homeMaticApi, homeMaticIpApi); + + // Act + await client.RemoveLinkAsync(SenderAddress, ReceiverAddress, CcuDeviceKind.HomeMaticIp); + + // Assert + A.CallTo(() => homeMaticIpApi.RemoveLinkAsync(SenderAddress, ReceiverAddress)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task SetLinkInfoAsync_DelegatesToApi() + { + // Arrange + var homeMaticApi = A.Fake(); + var homeMaticIpApi = A.Fake(); + var client = CreateClient(homeMaticApi, homeMaticIpApi); + + // Act + await client.SetLinkInfoAsync(SenderAddress, ReceiverAddress, "name", "desc"); + + // Assert + A.CallTo(() => homeMaticApi.SetLinkInfoAsync(SenderAddress, ReceiverAddress, "name", "desc")) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task GetLinkInfoAsync_ReturnsLinkInfoFromApiResponse() + { + // Arrange + var homeMaticApi = A.Fake(); + var homeMaticIpApi = A.Fake(); + A.CallTo(() => homeMaticApi.GetLinkInfoRawAsync(SenderAddress, ReceiverAddress)) + .Returns(Task.FromResult>(["the name", "the description"])); + + var client = CreateClient(homeMaticApi, homeMaticIpApi); + + // Act + var info = await client.GetLinkInfoAsync(SenderAddress, ReceiverAddress); + + // Assert + info.Name.Should().Be("the name"); + info.Description.Should().Be("the description"); + } + + [Theory] + [InlineData(null, "BIDCOS:2")] + [InlineData("", "BIDCOS:2")] + [InlineData("BIDCOS:1", null)] + [InlineData("BIDCOS:1", "")] + public async Task RemoveLinkAsync_NullOrWhitespaceAddress_ThrowsAndDoesNotCallApi(string? sender, + string? receiver) + { + // Arrange + var homeMaticApi = A.Fake(); + var homeMaticIpApi = A.Fake(); + var client = CreateClient(homeMaticApi, homeMaticIpApi); + + // Act + var act = () => client.RemoveLinkAsync(sender!, receiver!); + + // Assert + await act.Should().ThrowAsync(); + A.CallTo(homeMaticApi).MustNotHaveHappened(); + } + + [Fact] + public async Task SetLinkInfoAsync_NullName_ThrowsAndDoesNotCallApi() + { + // Arrange + var homeMaticApi = A.Fake(); + var homeMaticIpApi = A.Fake(); + var client = CreateClient(homeMaticApi, homeMaticIpApi); + + // Act + var act = () => client.SetLinkInfoAsync(SenderAddress, ReceiverAddress, null!, "d"); + + // Assert + await act.Should().ThrowAsync(); + A.CallTo(homeMaticApi).MustNotHaveHappened(); + } + + [Fact] + public async Task GetLinkInfoAsync_NullSender_ThrowsAndDoesNotCallApi() + { + // Arrange + var homeMaticApi = A.Fake(); + var homeMaticIpApi = A.Fake(); + var client = CreateClient(homeMaticApi, homeMaticIpApi); + + // Act + var act = () => client.GetLinkInfoAsync(null!, ReceiverAddress); + + // Assert + await act.Should().ThrowAsync(); + A.CallTo(homeMaticApi).MustNotHaveHappened(); + } + + [Fact] + public async Task LinkOperation_UnknownDeviceKind_ThrowsKeyNotFound() + { + // Arrange + var homeMaticApi = A.Fake(); + var jsonRpcClient = A.Fake(); + var connection = new XmlRpcApiConnection( + new XmlRpcApiAddress(new Uri("http://example.com"), CcuDeviceKind.HomeMatic), + homeMaticApi); + var xmlRpcApis = new Dictionary + { + { CcuDeviceKind.HomeMatic, connection } + }; + var client = new CcuClient(jsonRpcClient, xmlRpcApis, + A.Fake()); + + // Act + var act = () => client.GetAllLinksAsync(CcuDeviceKind.HomeMaticIp); + + // Assert + await act.Should().ThrowAsync(); + } + + private static CcuClient CreateClient(IHomeMaticXmlRpcApi homeMaticApi, + IHomeMaticXmlRpcApi homeMaticIpApi) + { + var jsonRpcClient = A.Fake(); + var homeMaticConnection = new XmlRpcApiConnection( + new XmlRpcApiAddress(new Uri("http://example.com"), CcuDeviceKind.HomeMatic), + homeMaticApi); + var homeMaticIpConnection = new XmlRpcApiConnection( + new XmlRpcApiAddress(new Uri("http://example.com"), CcuDeviceKind.HomeMaticIp), + homeMaticIpApi); + + var xmlRpcApis = new Dictionary + { + { CcuDeviceKind.HomeMatic, homeMaticConnection }, + { CcuDeviceKind.HomeMaticIp, homeMaticIpConnection } + }; + + return new CcuClient(jsonRpcClient, xmlRpcApis, A.Fake()); + } +} diff --git a/tests/CreativeCoders.HomeMatic.Tests/CcuClientTests.cs b/tests/CreativeCoders.HomeMatic.Tests/CcuClientTests.cs index 1da518c..60ebebe 100644 --- a/tests/CreativeCoders.HomeMatic.Tests/CcuClientTests.cs +++ b/tests/CreativeCoders.HomeMatic.Tests/CcuClientTests.cs @@ -489,7 +489,7 @@ public async Task GetCompleteDevicesAsync_BuilderThrows_PropagatesException() var ccuClient = CreateCcuClient(jsonRpcClient, homeMaticXmlRpcApi, homeMaticIpXmlRpcApi, completeBuilder); // Act - var act = ccuClient.GetCompleteDevicesAsync; + var act = () => ccuClient.GetCompleteDevicesAsync(); // Assert await act.Should().ThrowAsync().WithMessage("boom"); diff --git a/tests/CreativeCoders.HomeMatic.Tests/CcuDeviceChannelLinkTests.cs b/tests/CreativeCoders.HomeMatic.Tests/CcuDeviceChannelLinkTests.cs new file mode 100644 index 0000000..970f40a --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.Tests/CcuDeviceChannelLinkTests.cs @@ -0,0 +1,327 @@ +using CreativeCoders.HomeMatic.Core; +using CreativeCoders.HomeMatic.Core.Devices; +using CreativeCoders.HomeMatic.XmlRpc; +using CreativeCoders.HomeMatic.XmlRpc.Client; +using CreativeCoders.HomeMatic.XmlRpc.Devices; +using CreativeCoders.HomeMatic.XmlRpc.Links; +using FakeItEasy; +using AwesomeAssertions; + +namespace CreativeCoders.HomeMatic.Tests; + +public class CcuDeviceChannelLinkTests +{ + private const string ChannelAddress = "BIDCOS:1"; + private const string ReceiverAddress = "BIDCOS:2"; + + [Fact] + public async Task GetLinksAsync_WithDefaultFlags_PassesChannelAddressAndZeroFlagsToApi() + { + // Arrange + var api = A.Fake(); + var expected = new[] { new Link { Sender = ChannelAddress, Receiver = ReceiverAddress } }; + A.CallTo(() => api.GetLinksAsync(ChannelAddress, (int)GetLinksFlags.None)) + .Returns(Task.FromResult>(expected)); + + var channel = CreateChannel(api); + + // Act + var result = await channel.GetLinksAsync(); + + // Assert + result.Should().BeEquivalentTo(expected); + A.CallTo(() => api.GetLinksAsync(ChannelAddress, (int)GetLinksFlags.None)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task GetLinksAsync_WithFlags_ForwardsFlagsAsIntToApi() + { + // Arrange + var api = A.Fake(); + const GetLinksFlags flags = GetLinksFlags.Group | GetLinksFlags.SenderParamSet; + A.CallTo(() => api.GetLinksAsync(ChannelAddress, (int)flags)) + .Returns(Task.FromResult>([])); + + var channel = CreateChannel(api); + + // Act + await channel.GetLinksAsync(flags); + + // Assert + A.CallTo(() => api.GetLinksAsync(ChannelAddress, (int)flags)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task GetLinkPeersAsync_PassesChannelAddressToApi() + { + // Arrange + var api = A.Fake(); + var peers = new[] { ReceiverAddress, "BIDCOS:3" }; + A.CallTo(() => api.GetLinkPeersAsync(ChannelAddress)) + .Returns(Task.FromResult>(peers)); + + var channel = CreateChannel(api); + + // Act + var result = await channel.GetLinkPeersAsync(); + + // Assert + result.Should().BeEquivalentTo(peers); + } + + [Fact] + public async Task AddLinkToAsync_DelegatesToApiUsingChannelAddressAsSender() + { + // Arrange + var api = A.Fake(); + var channel = CreateChannel(api); + + // Act + await channel.AddLinkToAsync(ReceiverAddress, "name", "description"); + + // Assert + A.CallTo(() => api.AddLinkAsync(ChannelAddress, ReceiverAddress, "name", "description")) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task AddLinkToAsync_WithoutNameAndDescription_DefaultsToEmptyStrings() + { + // Arrange + var api = A.Fake(); + var channel = CreateChannel(api); + + // Act + await channel.AddLinkToAsync(ReceiverAddress); + + // Assert + A.CallTo(() => api.AddLinkAsync(ChannelAddress, ReceiverAddress, string.Empty, string.Empty)) + .MustHaveHappenedOnceExactly(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task AddLinkToAsync_NullOrWhitespaceReceiver_ThrowsAndDoesNotCallApi(string? receiver) + { + // Arrange + var api = A.Fake(); + var channel = CreateChannel(api); + + // Act + var act = () => channel.AddLinkToAsync(receiver!); + + // Assert + await act.Should().ThrowAsync(); + A.CallTo(api).MustNotHaveHappened(); + } + + [Fact] + public async Task AddLinkToAsync_NullName_ThrowsAndDoesNotCallApi() + { + // Arrange + var api = A.Fake(); + var channel = CreateChannel(api); + + // Act + var act = () => channel.AddLinkToAsync(ReceiverAddress, null!, "desc"); + + // Assert + await act.Should().ThrowAsync(); + A.CallTo(api).MustNotHaveHappened(); + } + + [Fact] + public async Task AddLinkToAsync_NullDescription_ThrowsAndDoesNotCallApi() + { + // Arrange + var api = A.Fake(); + var channel = CreateChannel(api); + + // Act + var act = () => channel.AddLinkToAsync(ReceiverAddress, "name", null!); + + // Assert + await act.Should().ThrowAsync(); + A.CallTo(api).MustNotHaveHappened(); + } + + [Fact] + public async Task RemoveLinkToAsync_DelegatesToApiUsingChannelAddressAsSender() + { + // Arrange + var api = A.Fake(); + var channel = CreateChannel(api); + + // Act + await channel.RemoveLinkToAsync(ReceiverAddress); + + // Assert + A.CallTo(() => api.RemoveLinkAsync(ChannelAddress, ReceiverAddress)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task RemoveLinkToAsync_NullReceiver_ThrowsAndDoesNotCallApi() + { + // Arrange + var api = A.Fake(); + var channel = CreateChannel(api); + + // Act + var act = () => channel.RemoveLinkToAsync(null!); + + // Assert + await act.Should().ThrowAsync(); + A.CallTo(api).MustNotHaveHappened(); + } + + [Fact] + public async Task SetLinkInfoAsync_NullReceiver_ThrowsAndDoesNotCallApi() + { + // Arrange + var api = A.Fake(); + var channel = CreateChannel(api); + + // Act + var act = () => channel.SetLinkInfoAsync(null!, "n", "d"); + + // Assert + await act.Should().ThrowAsync(); + A.CallTo(api).MustNotHaveHappened(); + } + + [Fact] + public async Task SetLinkInfoAsync_NullName_ThrowsAndDoesNotCallApi() + { + // Arrange + var api = A.Fake(); + var channel = CreateChannel(api); + + // Act + var act = () => channel.SetLinkInfoAsync(ReceiverAddress, null!, "d"); + + // Assert + await act.Should().ThrowAsync(); + A.CallTo(api).MustNotHaveHappened(); + } + + [Fact] + public async Task GetLinkInfoAsync_NullReceiver_ThrowsAndDoesNotCallApi() + { + // Arrange + var api = A.Fake(); + var channel = CreateChannel(api); + + // Act + var act = () => channel.GetLinkInfoAsync(null!); + + // Assert + await act.Should().ThrowAsync(); + A.CallTo(api).MustNotHaveHappened(); + } + + [Fact] + public async Task GetLinksAsync_ApiThrows_ExceptionPropagates() + { + // Arrange + var api = A.Fake(); + A.CallTo(() => api.GetLinksAsync(A._, A._)) + .Throws(new InvalidOperationException("boom")); + var channel = CreateChannel(api); + + // Act + var act = () => channel.GetLinksAsync(); + + // Assert + await act.Should().ThrowAsync().WithMessage("boom"); + } + + [Fact] + public async Task SetLinkInfoAsync_DelegatesToApi() + { + // Arrange + var api = A.Fake(); + var channel = CreateChannel(api); + + // Act + await channel.SetLinkInfoAsync(ReceiverAddress, "new name", "new description"); + + // Assert + A.CallTo(() => api.SetLinkInfoAsync(ChannelAddress, ReceiverAddress, "new name", "new description")) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task GetLinkInfoAsync_ReturnsLinkInfoFromApiResponse() + { + // Arrange + var api = A.Fake(); + A.CallTo(() => api.GetLinkInfoRawAsync(ChannelAddress, ReceiverAddress)) + .Returns(Task.FromResult>(["the name", "the description"])); + + var channel = CreateChannel(api); + + // Act + var info = await channel.GetLinkInfoAsync(ReceiverAddress); + + // Assert + info.Name.Should().Be("the name"); + info.Description.Should().Be("the description"); + } + + [Fact] + public async Task ActivateLinkParamsetAsync_DelegatesToApi() + { + // Arrange + var api = A.Fake(); + var channel = CreateChannel(api); + + // Act + await channel.ActivateLinkParamsetAsync(ReceiverAddress, longPress: true); + + // Assert + A.CallTo(() => api.ActivateLinkParamsetAsync(ChannelAddress, ReceiverAddress, true)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task ActivateLinkParamsetAsync_NullPeer_ThrowsAndDoesNotCallApi() + { + // Arrange + var api = A.Fake(); + var channel = CreateChannel(api); + + // Act + var act = () => channel.ActivateLinkParamsetAsync(null!, longPress: false); + + // Assert + await act.Should().ThrowAsync(); + A.CallTo(api).MustNotHaveHappened(); + } + + private static CcuDeviceChannel CreateChannel(IHomeMaticXmlRpcApi api) + { + return new CcuDeviceChannel(api) + { + Uri = new CcuDeviceUri + { + CcuHost = "localhost", + Kind = CcuDeviceKind.HomeMatic, + Address = ChannelAddress + }, + DeviceType = "TestChannel", + IsAesActive = false, + Interface = "BidCos-RF", + Version = 1, + Roaming = false, + ParamSets = ["MASTER", "VALUES"], + Index = 1, + Group = string.Empty, + ChannelDirection = ChannelDirection.None + }; + } +} diff --git a/tests/CreativeCoders.HomeMatic.Tests/CompleteCcuDeviceBuilderTests.cs b/tests/CreativeCoders.HomeMatic.Tests/CompleteCcuDeviceBuilderTests.cs index 6774b05..f56484b 100644 --- a/tests/CreativeCoders.HomeMatic.Tests/CompleteCcuDeviceBuilderTests.cs +++ b/tests/CreativeCoders.HomeMatic.Tests/CompleteCcuDeviceBuilderTests.cs @@ -1,4 +1,6 @@ +using CreativeCoders.HomeMatic.Core; using CreativeCoders.HomeMatic.Core.Devices; +using CreativeCoders.HomeMatic.XmlRpc.Links; using FakeItEasy; using AwesomeAssertions; @@ -181,6 +183,84 @@ public async Task BuildAsync_ChannelWithoutParamSets_ReturnsChannelWithEmptyPara .Which.ParamSetValues.Should().BeEmpty(); } + [Fact] + public async Task BuildAsync_WithoutOptions_DoesNotFetchLinks() + { + // Arrange + var device = A.Fake(); + var channel = A.Fake(); + + A.CallTo(() => device.Channels).Returns([channel]); + A.CallTo(() => device.ParamSets).Returns([]); + A.CallTo(() => channel.ParamSets).Returns([]); + + var builder = new CompleteCcuDeviceBuilder(); + + // Act + var completeDevice = await builder.BuildAsync(device); + + // Assert + A.CallTo(channel) + .Where(call => call.Method.Name == nameof(ICcuDeviceChannel.GetLinksAsync)) + .MustNotHaveHappened(); + completeDevice.Channels.Single().Links.Should().BeEmpty(); + } + + [Fact] + public async Task BuildAsync_WithIncludeLinks_FetchesLinksWithRequestedFlags() + { + // Arrange + var device = A.Fake(); + var channel = A.Fake(); + + A.CallTo(() => device.Channels).Returns([channel]); + A.CallTo(() => device.ParamSets).Returns([]); + A.CallTo(() => channel.ParamSets).Returns([]); + + var expectedLink = new Link { Sender = "X:1", Receiver = "Y:1", Name = "n", Description = "d" }; + const GetLinksFlags expectedFlags = GetLinksFlags.SenderParamSet; + A.CallTo(() => channel.GetLinksAsync(expectedFlags)) + .Returns(Task.FromResult>([expectedLink])); + + var builder = new CompleteCcuDeviceBuilder(); + var options = new CompleteCcuDeviceBuildOptions + { + IncludeLinks = true, + LinksFlags = expectedFlags + }; + + // Act + var completeDevice = await builder.BuildAsync(device, options); + + // Assert + A.CallTo(() => channel.GetLinksAsync(expectedFlags)).MustHaveHappenedOnceExactly(); + completeDevice.Channels.Single().Links.Should().ContainSingle() + .Which.Should().BeEquivalentTo(expectedLink); + } + + [Fact] + public async Task BuildAsync_WithIncludeLinksFalse_DoesNotFetchLinks() + { + // Arrange + var device = A.Fake(); + var channel = A.Fake(); + + A.CallTo(() => device.Channels).Returns([channel]); + A.CallTo(() => device.ParamSets).Returns([]); + A.CallTo(() => channel.ParamSets).Returns([]); + + var builder = new CompleteCcuDeviceBuilder(); + var options = new CompleteCcuDeviceBuildOptions { IncludeLinks = false }; + + // Act + await builder.BuildAsync(device, options); + + // Assert + A.CallTo(channel) + .Where(call => call.Method.Name == nameof(ICcuDeviceChannel.GetLinksAsync)) + .MustNotHaveHappened(); + } + private static void SetupParamSet(ICcuDeviceBase device, string paramSetKey, string name, object value) { A.CallTo(() => device.GetParamSetValuesAsync(paramSetKey)) diff --git a/tests/CreativeCoders.HomeMatic.Tests/Exporting/CompleteCcuDeviceChannelFakeBuilder.cs b/tests/CreativeCoders.HomeMatic.Tests/Exporting/CompleteCcuDeviceChannelFakeBuilder.cs index 3b9a9a7..235722e 100644 --- a/tests/CreativeCoders.HomeMatic.Tests/Exporting/CompleteCcuDeviceChannelFakeBuilder.cs +++ b/tests/CreativeCoders.HomeMatic.Tests/Exporting/CompleteCcuDeviceChannelFakeBuilder.cs @@ -1,6 +1,7 @@ using CreativeCoders.HomeMatic.Core; using CreativeCoders.HomeMatic.Core.Devices; using CreativeCoders.HomeMatic.XmlRpc; +using CreativeCoders.HomeMatic.XmlRpc.Links; using FakeItEasy; namespace CreativeCoders.HomeMatic.Tests.Exporting; @@ -12,8 +13,15 @@ internal sealed class CompleteCcuDeviceChannelFakeBuilder private int _index = 1; private string[] _paramSets = ["VALUES"]; private readonly List _paramSetValues = []; + private readonly List _links = []; private string _ccuHost = "ccu2.local"; + public CompleteCcuDeviceChannelFakeBuilder WithLink(Link link) + { + _links.Add(link); + return this; + } + public CompleteCcuDeviceChannelFakeBuilder WithAddress(string address) { _address = address; @@ -61,7 +69,7 @@ public CompleteCcuDeviceChannelFakeBuilder WithParamSet(string paramSetKey, Acti public ICompleteCcuDeviceChannel Build() { var channel = A.Fake(); - var channelData = A.Fake(); + var channelData = A.Fake(); var uri = new CcuDeviceUri { @@ -77,6 +85,7 @@ public ICompleteCcuDeviceChannel Build() A.CallTo(() => channel.ChannelData).Returns(channelData); A.CallTo(() => channel.ParamSetValues).Returns(_paramSetValues); + A.CallTo(() => channel.Links).Returns(_links); return channel; } diff --git a/tests/CreativeCoders.HomeMatic.Tests/Exporting/DeviceExporterTests.cs b/tests/CreativeCoders.HomeMatic.Tests/Exporting/DeviceExporterTests.cs index ea8111a..b68d801 100644 --- a/tests/CreativeCoders.HomeMatic.Tests/Exporting/DeviceExporterTests.cs +++ b/tests/CreativeCoders.HomeMatic.Tests/Exporting/DeviceExporterTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using AwesomeAssertions; using CreativeCoders.HomeMatic.Exporting; +using CreativeCoders.HomeMatic.XmlRpc.Links; namespace CreativeCoders.HomeMatic.Tests.Exporting; @@ -703,4 +704,157 @@ public async Task ExportDevicesAsync_WithOptions_AppliesFilterToEachDevice() paramSet.ParamSetKey.Should().Be("MASTER"); paramSet.Values.Select(v => v.Key).Should().BeEquivalentTo("BOOST_TIME"); } + + // ---- Links ------------------------------------------------------------ + + [Fact] + public void BuildExportData_WithoutIncludeLinks_LinksAreNull() + { + // Arrange + var device = new CompleteCcuDeviceFakeBuilder() + .WithChannel(c => c + .WithAddress("XYZ:1") + .WithLink(new Link { Sender = "XYZ:1", Receiver = "ABC:2", Name = "n", Description = "d" })) + .Build(); + var sut = new DeviceExporter(); + + // Act + var result = sut.BuildExportData(device); + + // Assert + result.Channels.Single().Links.Should().BeNull(); + } + + [Fact] + public void BuildExportData_WithIncludeLinksButNoLinks_ReturnsEmptyList() + { + // Arrange + var device = new CompleteCcuDeviceFakeBuilder() + .WithChannel(c => c.WithAddress("XYZ:1")) + .Build(); + var options = new DeviceExportOptions { IncludeLinks = true }; + var sut = new DeviceExporter(); + + // Act + var result = sut.BuildExportData(device, options); + + // Assert + result.Channels.Single().Links.Should().NotBeNull(); + result.Channels.Single().Links.Should().BeEmpty(); + } + + [Fact] + public void BuildExportData_WithIncludeLinksAndLinks_MapsAllLinkFields() + { + // Arrange + var link = new Link + { + Sender = "XYZ:1", + Receiver = "ABC:2", + Name = "MyLink", + Description = "MyDescription" + }; + var device = new CompleteCcuDeviceFakeBuilder() + .WithChannel(c => c.WithAddress("XYZ:1").WithLink(link)) + .Build(); + var options = new DeviceExportOptions { IncludeLinks = true }; + var sut = new DeviceExporter(); + + // Act + var result = sut.BuildExportData(device, options); + + // Assert + var exportedLink = result.Channels.Single().Links.Should().ContainSingle().Subject; + exportedLink.Sender.Should().Be("XYZ:1"); + exportedLink.Receiver.Should().Be("ABC:2"); + exportedLink.Name.Should().Be("MyLink"); + exportedLink.Description.Should().Be("MyDescription"); + } + + [Fact] + public void BuildExportData_WithIncludeLinksAndMultipleLinks_PreservesOrder() + { + // Arrange + var device = new CompleteCcuDeviceFakeBuilder() + .WithChannel(c => c + .WithAddress("XYZ:1") + .WithLink(new Link { Sender = "XYZ:1", Receiver = "A:1", Name = "1", Description = "" }) + .WithLink(new Link { Sender = "XYZ:1", Receiver = "B:1", Name = "2", Description = "" }) + .WithLink(new Link { Sender = "XYZ:1", Receiver = "C:1", Name = "3", Description = "" })) + .Build(); + var options = new DeviceExportOptions { IncludeLinks = true }; + var sut = new DeviceExporter(); + + // Act + var result = sut.BuildExportData(device, options); + + // Assert + result.Channels.Single().Links!.Select(l => l.Receiver) + .Should().ContainInOrder("A:1", "B:1", "C:1"); + } + + [Fact] + public async Task ExportDeviceAsync_WithIncludeLinks_EmitsLinksArrayInJson() + { + // Arrange + var device = new CompleteCcuDeviceFakeBuilder() + .WithChannel(c => c + .WithAddress("XYZ:1") + .WithLink(new Link { Sender = "XYZ:1", Receiver = "ABC:2", Name = "n", Description = "d" })) + .Build(); + var options = new DeviceExportOptions { IncludeLinks = true, WriteIndented = false }; + var sut = new DeviceExporter(); + + // Act + var json = await sut.ExportDeviceAsync(device, options); + + // Assert + using var document = JsonDocument.Parse(json); + var channel = document.RootElement.GetProperty("channels")[0]; + channel.TryGetProperty("links", out var links).Should().BeTrue(); + links.GetArrayLength().Should().Be(1); + var first = links[0]; + first.GetProperty("sender").GetString().Should().Be("XYZ:1"); + first.GetProperty("receiver").GetString().Should().Be("ABC:2"); + first.GetProperty("name").GetString().Should().Be("n"); + first.GetProperty("description").GetString().Should().Be("d"); + } + + [Fact] + public async Task ExportDeviceAsync_WithoutIncludeLinks_OmitsLinksFromJson() + { + // Arrange + var device = new CompleteCcuDeviceFakeBuilder() + .WithChannel(c => c + .WithAddress("XYZ:1") + .WithLink(new Link { Sender = "XYZ:1", Receiver = "ABC:2", Name = "n", Description = "d" })) + .Build(); + var sut = new DeviceExporter(); + + // Act + var json = await sut.ExportDeviceAsync(device); + + // Assert + using var document = JsonDocument.Parse(json); + var channel = document.RootElement.GetProperty("channels")[0]; + channel.TryGetProperty("links", out _).Should().BeFalse(); + } + + [Fact] + public void DeviceExportOptions_ToBuildOptions_PropagatesIncludeLinksAndFlags() + { + // Arrange + var options = new DeviceExportOptions + { + IncludeLinks = true, + LinksFlags = GetLinksFlags.SenderParamSet | GetLinksFlags.ReceiverParamSet + }; + + // Act + var buildOptions = options.ToBuildOptions(); + + // Assert + buildOptions.IncludeLinks.Should().BeTrue(); + buildOptions.LinksFlags.Should().Be(GetLinksFlags.SenderParamSet | GetLinksFlags.ReceiverParamSet); + } } From 042f738457ad60a368b0a7f81a6b77b008638d2b Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Wed, 29 Apr 2026 17:09:27 +0200 Subject: [PATCH 14/16] feat(xmlrpc): implement `ParamSetDictionaryValueConverter` for flexible paramset deserialization - Added `ParamSetDictionaryValueConverter` to handle HomeMatic `getLinks` paramset conversion, tolerating deviations in XML-RPC response formats. - Updated `Link` model to include the new converter for `SenderParamSet` and `ReceiverParamSet`. - Introduced unit tests to ensure correct handling of various `XmlRpcValue` types, including edge cases. --- .../ParamSetDictionaryValueConverter.cs | 55 +++++++++++++ .../Links/Link.cs | 4 +- .../ParamSetDictionaryValueConverterTests.cs | 82 +++++++++++++++++++ 3 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 source/CreativeCoders.HomeMatic.XmlRpc/Converters/ParamSetDictionaryValueConverter.cs create mode 100644 tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Converters/ParamSetDictionaryValueConverterTests.cs diff --git a/source/CreativeCoders.HomeMatic.XmlRpc/Converters/ParamSetDictionaryValueConverter.cs b/source/CreativeCoders.HomeMatic.XmlRpc/Converters/ParamSetDictionaryValueConverter.cs new file mode 100644 index 0000000..e0fe437 --- /dev/null +++ b/source/CreativeCoders.HomeMatic.XmlRpc/Converters/ParamSetDictionaryValueConverter.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CreativeCoders.Net.XmlRpc.Definition; +using CreativeCoders.Net.XmlRpc.Model; +using CreativeCoders.Net.XmlRpc.Model.Values; + +namespace CreativeCoders.HomeMatic.XmlRpc.Converters; + +/// +/// Converts an XML-RPC value representing a parameter set into a +/// with string keys and object values. +/// +/// +/// HomeMatic / homegear CCUs occasionally return the SENDER_PARAMSET and +/// RECEIVER_PARAMSET members of a getLinks entry as an empty +/// instead of an empty when the +/// corresponding paramset flag was not requested. The default +/// implementation rejects this with an +/// . This converter tolerates the deviation +/// and returns an empty dictionary for any non-struct value. +/// +public class ParamSetDictionaryValueConverter : IXmlRpcMemberValueConverter +{ + /// + /// Converts an into a . + /// + /// The XML-RPC value to convert. + /// + /// A dictionary mapping member names to their underlying data when + /// is a ; an empty dictionary + /// otherwise. + /// + public object ConvertFromValue(XmlRpcValue xmlRpcValue) + { + if (xmlRpcValue is StructValue structValue) + { + return structValue.Value + .ToDictionary(member => member.Key, member => member.Value.Data); + } + + return new Dictionary(); + } + + /// + /// Converts a dictionary value into an . + /// + /// The value to convert. + /// This method is not implemented and always throws . + /// Always thrown; serialization of paramset dictionaries is not supported. + public XmlRpcValue ConvertFromObject(object value) + { + throw new NotImplementedException(); + } +} diff --git a/source/CreativeCoders.HomeMatic.XmlRpc/Links/Link.cs b/source/CreativeCoders.HomeMatic.XmlRpc/Links/Link.cs index 08bb8b9..88b8e15 100644 --- a/source/CreativeCoders.HomeMatic.XmlRpc/Links/Link.cs +++ b/source/CreativeCoders.HomeMatic.XmlRpc/Links/Link.cs @@ -62,7 +62,7 @@ public class Link /// flag was passed to getLinks; otherwise /// an empty dictionary. /// - [XmlRpcStructMember("SENDER_PARAMSET")] + [XmlRpcStructMember("SENDER_PARAMSET", Converter = typeof(ParamSetDictionaryValueConverter))] public Dictionary SenderParamSet { get; set; } = new(); /// @@ -73,6 +73,6 @@ public class Link /// flag was passed to getLinks; otherwise /// an empty dictionary. /// - [XmlRpcStructMember("RECEIVER_PARAMSET")] + [XmlRpcStructMember("RECEIVER_PARAMSET", Converter = typeof(ParamSetDictionaryValueConverter))] public Dictionary ReceiverParamSet { get; set; } = new(); } diff --git a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Converters/ParamSetDictionaryValueConverterTests.cs b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Converters/ParamSetDictionaryValueConverterTests.cs new file mode 100644 index 0000000..5ecee36 --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Converters/ParamSetDictionaryValueConverterTests.cs @@ -0,0 +1,82 @@ +using System.Collections.Generic; +using CreativeCoders.HomeMatic.XmlRpc.Converters; +using CreativeCoders.Net.XmlRpc.Model; +using CreativeCoders.Net.XmlRpc.Model.Values; +using AwesomeAssertions; + +namespace CreativeCoders.HomeMatic.XmlRpc.Tests.Converters; + +public class ParamSetDictionaryValueConverterTests +{ + [Fact] + public void ConvertFromValue_StringValue_ReturnsEmptyDictionary() + { + var sut = new ParamSetDictionaryValueConverter(); + + var result = sut.ConvertFromValue(new StringValue(string.Empty)); + + result.Should().BeOfType>().Which.Should().BeEmpty(); + } + + [Fact] + public void ConvertFromValue_NonEmptyStringValue_ReturnsEmptyDictionary() + { + var sut = new ParamSetDictionaryValueConverter(); + + var result = sut.ConvertFromValue(new StringValue("unexpected")); + + result.Should().BeOfType>().Which.Should().BeEmpty(); + } + + [Fact] + public void ConvertFromValue_IntegerValue_ReturnsEmptyDictionary() + { + var sut = new ParamSetDictionaryValueConverter(); + + var result = sut.ConvertFromValue(new IntegerValue(0)); + + result.Should().BeOfType>().Which.Should().BeEmpty(); + } + + [Fact] + public void ConvertFromValue_EmptyStruct_ReturnsEmptyDictionary() + { + var sut = new ParamSetDictionaryValueConverter(); + + var result = sut.ConvertFromValue(new StructValue(new Dictionary())); + + result.Should().BeOfType>().Which.Should().BeEmpty(); + } + + [Fact] + public void ConvertFromValue_PopulatedStruct_ReturnsDictionaryWithMembers() + { + var sut = new ParamSetDictionaryValueConverter(); + var members = new Dictionary + { + ["NAME"] = new StringValue("test"), + ["VALUE"] = new IntegerValue(42), + ["ENABLED"] = new BooleanValue(true) + }; + + var result = sut.ConvertFromValue(new StructValue(members)); + + result.Should().BeOfType>() + .Which.Should().BeEquivalentTo(new Dictionary + { + ["NAME"] = "test", + ["VALUE"] = 42, + ["ENABLED"] = true + }); + } + + [Fact] + public void ConvertFromObject_AnyValue_ThrowsNotImplementedException() + { + var sut = new ParamSetDictionaryValueConverter(); + + var act = () => sut.ConvertFromObject(new Dictionary()); + + act.Should().Throw(); + } +} From 8bae8a351330391f2944e67588a11310679d26f7 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Wed, 29 Apr 2026 17:52:13 +0200 Subject: [PATCH 15/16] feat(xmlrpc): add support for custom encoding in API builder - Introduced `UseEncoding` with `Encoding.Latin1` in `HomeMaticXmlRpcApiBuilder` to support custom character encoding. --- .../Client/HomeMaticXmlRpcApiBuilder.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/source/CreativeCoders.HomeMatic.XmlRpc/Client/HomeMaticXmlRpcApiBuilder.cs b/source/CreativeCoders.HomeMatic.XmlRpc/Client/HomeMaticXmlRpcApiBuilder.cs index 4bd7729..ee59697 100644 --- a/source/CreativeCoders.HomeMatic.XmlRpc/Client/HomeMaticXmlRpcApiBuilder.cs +++ b/source/CreativeCoders.HomeMatic.XmlRpc/Client/HomeMaticXmlRpcApiBuilder.cs @@ -1,4 +1,5 @@ using System; +using System.Text; using CreativeCoders.Core; using CreativeCoders.Net.XmlRpc.Proxy; using JetBrains.Annotations; @@ -50,6 +51,7 @@ public IHomeMaticXmlRpcApi Build() } return _proxyBuilder + .UseEncoding(Encoding.Latin1) .ForUrl(_url) .Build(); } From 277f44d9f502d744e7aa19cfb95328e79b140937 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Wed, 29 Apr 2026 17:58:20 +0200 Subject: [PATCH 16/16] test(xmlrpc): add `UseEncoding` mock to `HomeMaticXmlRpcApiBuilderTests` - Updated unit tests to include mocking for `UseEncoding` in `HomeMaticXmlRpcApiBuilder`. - Ensured proper handling of `Encoding` in the API builder's test scenarios. - Improved null check validation and refactored test consistency. --- .../Client/HomeMaticXmlRpcApiBuilderTests.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Client/HomeMaticXmlRpcApiBuilderTests.cs b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Client/HomeMaticXmlRpcApiBuilderTests.cs index 421a6c6..f6fa0ae 100644 --- a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Client/HomeMaticXmlRpcApiBuilderTests.cs +++ b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Client/HomeMaticXmlRpcApiBuilderTests.cs @@ -1,3 +1,4 @@ +using System.Text; using CreativeCoders.HomeMatic.XmlRpc.Client; using CreativeCoders.Net.XmlRpc.Proxy; using FakeItEasy; @@ -25,7 +26,7 @@ public void ForUrl_NullUri_ThrowsArgumentNullException() var sut = new HomeMaticXmlRpcApiBuilder(proxyBuilder); // Act - Action act = () => sut.ForUrl((Uri) null!); + Action act = () => sut.ForUrl((Uri)null!); // Assert act.Should().Throw(); @@ -38,6 +39,7 @@ public void Build_AfterForUrl_DelegatesToProxyBuilderWithConfiguredUrl() var proxyBuilder = A.Fake>(); var fakeApi = A.Fake(); var url = new Uri("http://localhost:2001/"); + A.CallTo(() => proxyBuilder.UseEncoding(A._)).Returns(proxyBuilder); A.CallTo(() => proxyBuilder.ForUrl(url)).Returns(proxyBuilder); A.CallTo(() => proxyBuilder.Build()).Returns(fakeApi); var sut = new HomeMaticXmlRpcApiBuilder(proxyBuilder); @@ -87,6 +89,7 @@ public void Build_AfterForUrlCalledTwice_UsesLastUrl() var fakeApi = A.Fake(); var firstUrl = new Uri("http://first.local/"); var secondUrl = new Uri("http://second.local/"); + A.CallTo(() => proxyBuilder.UseEncoding(A._)).Returns(proxyBuilder); A.CallTo(() => proxyBuilder.ForUrl(A._)).Returns(proxyBuilder); A.CallTo(() => proxyBuilder.Build()).Returns(fakeApi); var sut = new HomeMaticXmlRpcApiBuilder(proxyBuilder); @@ -105,6 +108,7 @@ public void ForUrl_XmlRpcApiAddress_DelegatesToUriOverloadWithDerivedUrl() // Arrange var proxyBuilder = A.Fake>(); var fakeApi = A.Fake(); + A.CallTo(() => proxyBuilder.UseEncoding(A._)).Returns(proxyBuilder); A.CallTo(() => proxyBuilder.ForUrl(A._)).Returns(proxyBuilder); A.CallTo(() => proxyBuilder.Build()).Returns(fakeApi); var apiAddress = new XmlRpcApiAddress(new Uri("http://192.168.1.100/"), CcuDeviceKind.HomeMatic);