From 477e552699bcc750855af05a70a1b229c7c9d564 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 26 Apr 2026 12:32:52 +0200 Subject: [PATCH 1/7] feat(homeMatic): add firmware backup functionality for CCU - Added `CcuSessionClient` for CCU JSON-RPC session management. - Implemented `FirmwareBackupClient` and `FirmwareBackupDownloader` for handling firmware backup downloads. - Introduced `FirmwareBackupClientFactory` to simplify client creation. - Created options and results classes: `FirmwareBackupOptions` and `FirmwareBackupResult`. - Provided extension methods via `FirmwareBackupServiceCollectionExtensions` for DI registration. - Added detailed unit and integration tests to ensure functionality. --- .../CreativeCoders.HomeMatic.csproj | 5 +- .../FirmwareBackup/FirmwareBackupClient.cs | 95 +++++++++ .../FirmwareBackupClientFactory.cs | 46 +++++ .../FirmwareBackup/FirmwareBackupException.cs | 51 +++++ .../FirmwareBackup/FirmwareBackupOptions.cs | 62 ++++++ .../FirmwareBackup/FirmwareBackupResult.cs | 71 +++++++ ...rmwareBackupServiceCollectionExtensions.cs | 32 +++ .../FirmwareBackup/IFirmwareBackupClient.cs | 29 +++ .../IFirmwareBackupClientFactory.cs | 17 ++ .../Internal/CcuSessionClient.cs | 126 ++++++++++++ .../Internal/FirmwareBackupDownloader.cs | 110 +++++++++++ .../Internal/ICcuSessionClient.cs | 25 +++ .../Internal/IFirmwareBackupDownloader.cs | 29 +++ .../HomeMaticServiceCollectionExtensions.cs | 2 + .../Ccu/CcuCommandGroup.cs | 11 ++ ...wareBackupClientFactoryIntegrationTests.cs | 185 ++++++++++++++++++ .../FirmwareBackupOptionsTests.cs | 54 +++++ ...eBackupServiceCollectionExtensionsTests.cs | 53 +++++ .../QueueingHttpMessageHandler.cs | 64 ++++++ .../SingleHandlerHttpClientFactory.cs | 20 ++ 20 files changed, 1086 insertions(+), 1 deletion(-) create mode 100644 source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupClient.cs create mode 100644 source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupClientFactory.cs create mode 100644 source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupException.cs create mode 100644 source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupOptions.cs create mode 100644 source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupResult.cs create mode 100644 source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupServiceCollectionExtensions.cs create mode 100644 source/CreativeCoders.HomeMatic/FirmwareBackup/IFirmwareBackupClient.cs create mode 100644 source/CreativeCoders.HomeMatic/FirmwareBackup/IFirmwareBackupClientFactory.cs create mode 100644 source/CreativeCoders.HomeMatic/FirmwareBackup/Internal/CcuSessionClient.cs create mode 100644 source/CreativeCoders.HomeMatic/FirmwareBackup/Internal/FirmwareBackupDownloader.cs create mode 100644 source/CreativeCoders.HomeMatic/FirmwareBackup/Internal/ICcuSessionClient.cs create mode 100644 source/CreativeCoders.HomeMatic/FirmwareBackup/Internal/IFirmwareBackupDownloader.cs create mode 100644 source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/CcuCommandGroup.cs create mode 100644 tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupClientFactoryIntegrationTests.cs create mode 100644 tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupOptionsTests.cs create mode 100644 tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupServiceCollectionExtensionsTests.cs create mode 100644 tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/QueueingHttpMessageHandler.cs create mode 100644 tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/SingleHandlerHttpClientFactory.cs diff --git a/source/CreativeCoders.HomeMatic/CreativeCoders.HomeMatic.csproj b/source/CreativeCoders.HomeMatic/CreativeCoders.HomeMatic.csproj index aa6a69c..6b1dc00 100644 --- a/source/CreativeCoders.HomeMatic/CreativeCoders.HomeMatic.csproj +++ b/source/CreativeCoders.HomeMatic/CreativeCoders.HomeMatic.csproj @@ -1,4 +1,4 @@ - + enable @@ -11,5 +11,8 @@ + + + diff --git a/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupClient.cs b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupClient.cs new file mode 100644 index 0000000..0d85a5e --- /dev/null +++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupClient.cs @@ -0,0 +1,95 @@ +using CreativeCoders.Core; +using CreativeCoders.HomeMatic.FirmwareBackup.Internal; + +namespace CreativeCoders.HomeMatic.FirmwareBackup; + +/// +/// Default implementation. Orchestrates login, backup download +/// and logout against a HomeMatic CCU. +/// +public sealed class FirmwareBackupClient : IFirmwareBackupClient +{ + private readonly ICcuSessionClient _sessionClient; + private readonly IFirmwareBackupDownloader _downloader; + private readonly FirmwareBackupOptions _options; + + internal FirmwareBackupClient( + ICcuSessionClient sessionClient, + IFirmwareBackupDownloader downloader, + FirmwareBackupOptions options) + { + _sessionClient = Ensure.NotNull(sessionClient); + _downloader = Ensure.NotNull(downloader); + _options = Ensure.NotNull(options); + } + + /// + public async Task CreateBackupAsync(CancellationToken cancellationToken = default) + { + var sessionId = await _sessionClient + .LoginAsync(_options.Credential.UserName, _options.Credential.Password, cancellationToken) + .ConfigureAwait(false); + + try + { + var download = await _downloader.DownloadAsync(sessionId, cancellationToken).ConfigureAwait(false); + + return new FirmwareBackupResult( + download.Content, + download.FileName, + download.ContentLength, + download.HttpResources, + new LogoutDisposable(_sessionClient, sessionId)); + } + catch + { + await _sessionClient.LogoutAsync(sessionId, CancellationToken.None).ConfigureAwait(false); + throw; + } + } + + /// + public async Task CreateBackupToFileAsync(string targetFilePath, CancellationToken cancellationToken = default) + { + Ensure.IsNotNullOrWhitespace(targetFilePath); + + await using var backup = await CreateBackupAsync(cancellationToken).ConfigureAwait(false); + + var resolvedPath = ResolveFilePath(targetFilePath, backup.FileName); + + var directory = Path.GetDirectoryName(resolvedPath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + await using var fileStream = File.Create(resolvedPath); + await backup.Content.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); + + return resolvedPath; + } + + private static string ResolveFilePath(string targetFilePath, string suggestedFileName) + { + if (Directory.Exists(targetFilePath)) + { + return Path.Combine(targetFilePath, suggestedFileName); + } + + if (targetFilePath.EndsWith(Path.DirectorySeparatorChar) || + targetFilePath.EndsWith(Path.AltDirectorySeparatorChar)) + { + return Path.Combine(targetFilePath, suggestedFileName); + } + + return targetFilePath; + } + + private sealed class LogoutDisposable(ICcuSessionClient sessionClient, string sessionId) : IAsyncDisposable + { + public async ValueTask DisposeAsync() + { + await sessionClient.LogoutAsync(sessionId, CancellationToken.None).ConfigureAwait(false); + } + } +} diff --git a/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupClientFactory.cs b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupClientFactory.cs new file mode 100644 index 0000000..345921a --- /dev/null +++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupClientFactory.cs @@ -0,0 +1,46 @@ +using CreativeCoders.Core; +using CreativeCoders.HomeMatic.FirmwareBackup.Internal; + +namespace CreativeCoders.HomeMatic.FirmwareBackup; + +/// +/// Default implementation. Resolves the configured +/// via and wires up a +/// with its internal collaborators. +/// +public sealed class FirmwareBackupClientFactory : IFirmwareBackupClientFactory +{ + /// + /// Name of the named registered for firmware backup operations. + /// + public const string HttpClientName = "CreativeCoders.HomeMatic.FirmwareBackup"; + + private readonly IHttpClientFactory _httpClientFactory; + + /// + /// Initializes a new instance of . + /// + /// Factory used to obtain the named HTTP client. + public FirmwareBackupClientFactory(IHttpClientFactory httpClientFactory) + { + _httpClientFactory = Ensure.NotNull(httpClientFactory); + } + + /// + public IFirmwareBackupClient Create(FirmwareBackupOptions options) + { + Ensure.NotNull(options); + + var httpClient = _httpClientFactory.CreateClient(HttpClientName); + httpClient.Timeout = options.Timeout; + + var sessionClient = new CcuSessionClient(httpClient, options.BaseUrl, options.JsonRpcPath); + var downloader = new FirmwareBackupDownloader( + httpClient, + options.BaseUrl, + options.BackupCgiPath, + options.BackupAction); + + return new FirmwareBackupClient(sessionClient, downloader, options); + } +} diff --git a/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupException.cs b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupException.cs new file mode 100644 index 0000000..6a5452c --- /dev/null +++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupException.cs @@ -0,0 +1,51 @@ +using System.Net; +using JetBrains.Annotations; + +namespace CreativeCoders.HomeMatic.FirmwareBackup; + +/// +/// Exception thrown when a firmware backup operation against a HomeMatic CCU fails. +/// +[PublicAPI] +public class FirmwareBackupException : Exception +{ + /// + /// Initializes a new instance of . + /// + /// A human-readable description of the failure. + public FirmwareBackupException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of with an inner exception. + /// + /// A human-readable description of the failure. + /// The exception that caused the failure. + public FirmwareBackupException(string message, Exception innerException) : base(message, innerException) + { + } + + /// + /// Initializes a new instance of describing an HTTP failure. + /// + /// A human-readable description of the failure. + /// HTTP status code returned by the CCU. + /// Optional truncated response body for diagnostics. + public FirmwareBackupException(string message, HttpStatusCode statusCode, string? responseBody = null) + : base(message) + { + StatusCode = statusCode; + ResponseBody = responseBody; + } + + /// + /// Gets the HTTP status code returned by the CCU, if available. + /// + public HttpStatusCode? StatusCode { get; } + + /// + /// Gets a (possibly truncated) snippet of the CCU response body for diagnostics. + /// + public string? ResponseBody { get; } +} diff --git a/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupOptions.cs b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupOptions.cs new file mode 100644 index 0000000..3a278c8 --- /dev/null +++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupOptions.cs @@ -0,0 +1,62 @@ +using System.Net; +using CreativeCoders.Core; +using JetBrains.Annotations; + +namespace CreativeCoders.HomeMatic.FirmwareBackup; + +/// +/// Connection and behavior options used to create a firmware backup of a HomeMatic CCU. +/// +[PublicAPI] +public class FirmwareBackupOptions +{ + /// + /// Initializes a new instance of . + /// + /// Base URL of the CCU (e.g. https://homematic-ccu.local). + /// Credentials of a CCU user that is allowed to download backups. + public FirmwareBackupOptions(Uri baseUrl, NetworkCredential credential) + { + BaseUrl = Ensure.NotNull(baseUrl); + Credential = Ensure.NotNull(credential); + } + + /// + /// Gets the base URL of the CCU. + /// + public Uri BaseUrl { get; } + + /// + /// Gets the credentials used to log in against the CCU. + /// + public NetworkCredential Credential { get; } + + /// + /// Gets or sets the relative path of the JSON-RPC endpoint used for login/logout. Default: /api/homematic.cgi. + /// + public string JsonRpcPath { get; set; } = "/api/homematic.cgi"; + + /// + /// Gets or sets the relative path of the CGI endpoint that produces the firmware backup file. + /// Default: /config/cp_security.cgi. + /// + public string BackupCgiPath { get; set; } = "/config/cp_security.cgi"; + + /// + /// Gets or sets the form action value sent to the backup CGI endpoint. Default: create_backup. + /// + public string BackupAction { get; set; } = "create_backup"; + + /// + /// Gets or sets a value indicating whether the HTTP client should accept any (including self-signed) + /// server certificate. CCU devices typically use a self-signed certificate, therefore the default is + /// . + /// + public bool AcceptAnyServerCertificate { get; set; } = true; + + /// + /// Gets or sets the request timeout used for both the JSON-RPC and the CGI download call. + /// Default: 5 minutes (creating a backup on the CCU can take a while). + /// + public TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(5); +} diff --git a/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupResult.cs b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupResult.cs new file mode 100644 index 0000000..f7d1a3e --- /dev/null +++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupResult.cs @@ -0,0 +1,71 @@ +using CreativeCoders.Core; +using JetBrains.Annotations; + +namespace CreativeCoders.HomeMatic.FirmwareBackup; + +/// +/// Represents the result of a firmware backup download from a HomeMatic CCU. +/// +/// +/// The instance owns the underlying and any HTTP resources required to read it. +/// Always dispose the result to release the connection. +/// +[PublicAPI] +public sealed class FirmwareBackupResult : IAsyncDisposable, IDisposable +{ + private readonly IAsyncDisposable[] _additionalResources; + + /// + /// Initializes a new instance of . + /// + /// Stream containing the backup payload. + /// Suggested file name for the backup (e.g. ccu_backup.sbk). + /// Optional content length in bytes if reported by the server. + /// + /// Optional additional resources (such as the underlying ) that need + /// to be disposed together with the content stream. + /// + public FirmwareBackupResult( + Stream content, + string fileName, + long? contentLength = null, + params IAsyncDisposable[] additionalResources) + { + Content = Ensure.NotNull(content); + FileName = Ensure.IsNotNullOrWhitespace(fileName); + ContentLength = contentLength; + _additionalResources = additionalResources ?? []; + } + + /// + /// Gets the stream that contains the backup payload. + /// + public Stream Content { get; } + + /// + /// Gets the suggested file name for the downloaded backup. + /// + public string FileName { get; } + + /// + /// Gets the content length in bytes if reported by the CCU; otherwise . + /// + public long? ContentLength { get; } + + /// + public async ValueTask DisposeAsync() + { + await Content.DisposeAsync().ConfigureAwait(false); + + foreach (var resource in _additionalResources) + { + await resource.DisposeAsync().ConfigureAwait(false); + } + } + + /// + public void Dispose() + { + DisposeAsync().AsTask().GetAwaiter().GetResult(); + } +} diff --git a/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupServiceCollectionExtensions.cs b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupServiceCollectionExtensions.cs new file mode 100644 index 0000000..fe98c4f --- /dev/null +++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupServiceCollectionExtensions.cs @@ -0,0 +1,32 @@ +using JetBrains.Annotations; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CreativeCoders.HomeMatic.FirmwareBackup; + +/// +/// Extension methods for registering the firmware backup feature on an . +/// +[PublicAPI] +public static class FirmwareBackupServiceCollectionExtensions +{ + /// + /// Registers and the named + /// used to talk to a HomeMatic CCU. + /// + /// The service collection to register the services on. + /// The same instance to allow chaining calls. + public static IServiceCollection AddHomeMaticFirmwareBackup(this IServiceCollection services) + { + services + .AddHttpClient(FirmwareBackupClientFactory.HttpClientName) + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (_, _, _, _) => true + }); + + services.TryAddTransient(); + + return services; + } +} diff --git a/source/CreativeCoders.HomeMatic/FirmwareBackup/IFirmwareBackupClient.cs b/source/CreativeCoders.HomeMatic/FirmwareBackup/IFirmwareBackupClient.cs new file mode 100644 index 0000000..02e6755 --- /dev/null +++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/IFirmwareBackupClient.cs @@ -0,0 +1,29 @@ +using JetBrains.Annotations; + +namespace CreativeCoders.HomeMatic.FirmwareBackup; + +/// +/// Client for creating and downloading a firmware backup of a HomeMatic CCU. +/// +[PublicAPI] +public interface IFirmwareBackupClient +{ + /// + /// Creates a firmware backup on the CCU and returns it as a stream wrapped in a + /// . The caller is responsible for disposing the result. + /// + /// Cancellation token. + /// The backup result containing stream and metadata. + Task CreateBackupAsync(CancellationToken cancellationToken = default); + + /// + /// Creates a firmware backup on the CCU and writes it to the given target file. + /// + /// + /// Either an absolute file path or a directory path. If a directory is given, the file name + /// reported by the CCU is appended. + /// + /// Cancellation token. + /// The full path of the written backup file. + Task CreateBackupToFileAsync(string targetFilePath, CancellationToken cancellationToken = default); +} diff --git a/source/CreativeCoders.HomeMatic/FirmwareBackup/IFirmwareBackupClientFactory.cs b/source/CreativeCoders.HomeMatic/FirmwareBackup/IFirmwareBackupClientFactory.cs new file mode 100644 index 0000000..a6b5434 --- /dev/null +++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/IFirmwareBackupClientFactory.cs @@ -0,0 +1,17 @@ +using JetBrains.Annotations; + +namespace CreativeCoders.HomeMatic.FirmwareBackup; + +/// +/// Factory for creating instances configured for a specific CCU. +/// +[PublicAPI] +public interface IFirmwareBackupClientFactory +{ + /// + /// Creates a new using the given options. + /// + /// Connection and behavior options for the CCU. + /// A new instance. + IFirmwareBackupClient Create(FirmwareBackupOptions options); +} diff --git a/source/CreativeCoders.HomeMatic/FirmwareBackup/Internal/CcuSessionClient.cs b/source/CreativeCoders.HomeMatic/FirmwareBackup/Internal/CcuSessionClient.cs new file mode 100644 index 0000000..94feb25 --- /dev/null +++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/Internal/CcuSessionClient.cs @@ -0,0 +1,126 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using CreativeCoders.Core; + +namespace CreativeCoders.HomeMatic.FirmwareBackup.Internal; + +/// +/// Default implementation that talks to the CCU JSON-RPC endpoint via +/// . +/// +internal sealed class CcuSessionClient : ICcuSessionClient +{ + private readonly HttpClient _httpClient; + private readonly Uri _jsonRpcUrl; + + public CcuSessionClient(HttpClient httpClient, Uri baseUrl, string jsonRpcPath) + { + _httpClient = Ensure.NotNull(httpClient); + Ensure.NotNull(baseUrl); + Ensure.IsNotNullOrWhitespace(jsonRpcPath); + + _jsonRpcUrl = new Uri(baseUrl, jsonRpcPath); + } + + public async Task LoginAsync(string userName, string password, CancellationToken cancellationToken = default) + { + Ensure.IsNotNullOrWhitespace(userName); + Ensure.NotNull(password); + + var payload = new + { + version = "1.1", + method = "Session.login", + @params = new { username = userName, password } + }; + + var response = await PostJsonRpcAsync(payload, cancellationToken).ConfigureAwait(false); + + var sessionId = ReadResultString(response); + + if (string.IsNullOrWhiteSpace(sessionId)) + { + throw new FirmwareBackupException( + "CCU did not return a session id. Please verify the credentials."); + } + + return sessionId!; + } + + public async Task LogoutAsync(string sessionId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(sessionId)) + { + return; + } + + var payload = new + { + version = "1.1", + method = "Session.logout", + @params = new { _session_id_ = sessionId } + }; + + try + { + await PostJsonRpcAsync(payload, cancellationToken).ConfigureAwait(false); + } + catch + { + // Logout is best-effort; do not propagate errors when releasing a session. + } + } + + private async Task PostJsonRpcAsync(object payload, CancellationToken cancellationToken) + { + var json = JsonSerializer.Serialize(payload); + + using var request = new HttpRequestMessage(HttpMethod.Post, _jsonRpcUrl) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + throw new FirmwareBackupException( + $"JSON-RPC call against CCU failed with HTTP status {(int)response.StatusCode}.", + response.StatusCode, + Truncate(body)); + } + + return body; + } + + private static string? ReadResultString(string jsonResponse) + { + using var document = JsonDocument.Parse(jsonResponse); + var root = document.RootElement; + + if (root.TryGetProperty("error", out var errorElement) && errorElement.ValueKind != JsonValueKind.Null) + { + var message = errorElement.TryGetProperty("message", out var msgEl) && msgEl.ValueKind == JsonValueKind.String + ? msgEl.GetString() + : errorElement.ToString(); + + throw new FirmwareBackupException($"CCU returned a JSON-RPC error: {message}"); + } + + if (!root.TryGetProperty("result", out var resultElement)) + { + return null; + } + + return resultElement.ValueKind == JsonValueKind.String ? resultElement.GetString() : resultElement.ToString(); + } + + private static string Truncate(string value) + { + const int maxLength = 512; + return value.Length <= maxLength ? value : value[..maxLength]; + } +} diff --git a/source/CreativeCoders.HomeMatic/FirmwareBackup/Internal/FirmwareBackupDownloader.cs b/source/CreativeCoders.HomeMatic/FirmwareBackup/Internal/FirmwareBackupDownloader.cs new file mode 100644 index 0000000..d7f834e --- /dev/null +++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/Internal/FirmwareBackupDownloader.cs @@ -0,0 +1,110 @@ +using CreativeCoders.Core; + +namespace CreativeCoders.HomeMatic.FirmwareBackup.Internal; + +/// +/// Default that calls the CCU CGI backup endpoint. +/// +internal sealed class FirmwareBackupDownloader : IFirmwareBackupDownloader +{ + private const string DefaultFileName = "ccu_backup.sbk"; + + private readonly HttpClient _httpClient; + private readonly Uri _backupUrl; + private readonly string _backupAction; + + public FirmwareBackupDownloader(HttpClient httpClient, Uri baseUrl, string backupCgiPath, string backupAction) + { + _httpClient = Ensure.NotNull(httpClient); + Ensure.NotNull(baseUrl); + Ensure.IsNotNullOrWhitespace(backupCgiPath); + + _backupUrl = new Uri(baseUrl, backupCgiPath); + _backupAction = Ensure.IsNotNullOrWhitespace(backupAction); + } + + public async Task DownloadAsync(string sessionId, CancellationToken cancellationToken = default) + { + Ensure.IsNotNullOrWhitespace(sessionId); + + var requestUri = BuildRequestUri(sessionId); + + var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + + var response = await _httpClient + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + var body = await SafeReadAsync(response, cancellationToken).ConfigureAwait(false); + + response.Dispose(); + request.Dispose(); + + throw new FirmwareBackupException( + $"Firmware backup download failed with HTTP status {(int)response.StatusCode}.", + response.StatusCode, + body); + } + + var fileName = ResolveFileName(response); + var contentLength = response.Content.Headers.ContentLength; + + var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + + return new FirmwareBackupDownloadResult( + stream, + fileName, + contentLength, + new HttpResources(request, response)); + } + + private static string ResolveFileName(HttpResponseMessage response) + { + var disposition = response.Content.Headers.ContentDisposition; + + var fileName = disposition?.FileNameStar ?? disposition?.FileName; + + if (string.IsNullOrWhiteSpace(fileName)) + { + return DefaultFileName; + } + + return fileName!.Trim('"'); + } + + private Uri BuildRequestUri(string sessionId) + { + var encodedSid = Uri.EscapeDataString($"@{sessionId}@"); + var encodedAction = Uri.EscapeDataString(_backupAction); + + var separator = string.IsNullOrEmpty(_backupUrl.Query) ? "?" : "&"; + var query = $"{separator}sid={encodedSid}&action={encodedAction}"; + + return new Uri(_backupUrl + query); + } + + private static async Task SafeReadAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + try + { + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + return body.Length <= 512 ? body : body[..512]; + } + catch + { + return null; + } + } + + private sealed class HttpResources(HttpRequestMessage request, HttpResponseMessage response) : IAsyncDisposable + { + public ValueTask DisposeAsync() + { + response.Dispose(); + request.Dispose(); + return ValueTask.CompletedTask; + } + } +} diff --git a/source/CreativeCoders.HomeMatic/FirmwareBackup/Internal/ICcuSessionClient.cs b/source/CreativeCoders.HomeMatic/FirmwareBackup/Internal/ICcuSessionClient.cs new file mode 100644 index 0000000..92bec98 --- /dev/null +++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/Internal/ICcuSessionClient.cs @@ -0,0 +1,25 @@ +namespace CreativeCoders.HomeMatic.FirmwareBackup.Internal; + +/// +/// Performs JSON-RPC login/logout against a HomeMatic CCU to obtain a session id usable for +/// subsequent CGI calls (e.g. firmware backup download). +/// +internal interface ICcuSessionClient +{ + /// + /// Logs in against the CCU and returns the session id. + /// + /// Username used for login. + /// Password used for login. + /// Cancellation token. + /// The session id assigned by the CCU. + Task LoginAsync(string userName, string password, CancellationToken cancellationToken = default); + + /// + /// Logs out the given session. Errors are swallowed so that this method can safely be called from a + /// finally block. + /// + /// Session id previously returned by . + /// Cancellation token. + Task LogoutAsync(string sessionId, CancellationToken cancellationToken = default); +} diff --git a/source/CreativeCoders.HomeMatic/FirmwareBackup/Internal/IFirmwareBackupDownloader.cs b/source/CreativeCoders.HomeMatic/FirmwareBackup/Internal/IFirmwareBackupDownloader.cs new file mode 100644 index 0000000..cb2fe02 --- /dev/null +++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/Internal/IFirmwareBackupDownloader.cs @@ -0,0 +1,29 @@ +namespace CreativeCoders.HomeMatic.FirmwareBackup.Internal; + +/// +/// Triggers backup creation on a HomeMatic CCU and downloads the resulting .sbk file. +/// +internal interface IFirmwareBackupDownloader +{ + /// + /// Downloads the firmware backup using the given CCU session id. + /// + /// Session id obtained from a prior login. + /// Cancellation token. + /// The download result containing stream, metadata and HTTP resources. + Task DownloadAsync(string sessionId, CancellationToken cancellationToken = default); +} + +/// +/// Internal result type produced by . +/// +/// The backup payload stream. +/// The file name reported by the CCU (or a default). +/// Optional content length in bytes. +/// Disposable wrapping the underlying HTTP request/response. +internal sealed record FirmwareBackupDownloadResult( + Stream Content, + string FileName, + long? ContentLength, + IAsyncDisposable HttpResources); + diff --git a/source/CreativeCoders.HomeMatic/HomeMaticServiceCollectionExtensions.cs b/source/CreativeCoders.HomeMatic/HomeMaticServiceCollectionExtensions.cs index 18a11c9..3c08dc2 100644 --- a/source/CreativeCoders.HomeMatic/HomeMaticServiceCollectionExtensions.cs +++ b/source/CreativeCoders.HomeMatic/HomeMaticServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using CreativeCoders.HomeMatic.Core; using CreativeCoders.HomeMatic.Exporting; +using CreativeCoders.HomeMatic.FirmwareBackup; using CreativeCoders.HomeMatic.JsonRpc; using CreativeCoders.HomeMatic.XmlRpc; using JetBrains.Annotations; @@ -29,6 +30,7 @@ public static IServiceCollection AddHomeMatic(this IServiceCollection services) { services.AddHomeMaticXmlRpc(); services.AddHomeMaticJsonRpc(); + services.AddHomeMaticFirmwareBackup(); services.TryAddTransient(); services.TryAddTransient(); 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..5e31c96 --- /dev/null +++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/CcuCommandGroup.cs @@ -0,0 +1,11 @@ +using CreativeCoders.Cli.Core; +using CreativeCoders.HomeMatic.Tools.Cli.Commands.Ccu; + +[assembly: CliCommandGroup([CcuCommandGroup.Name], "Commands for working with a HomeMatic CCU")] + +namespace CreativeCoders.HomeMatic.Tools.Cli.Commands.Ccu; + +public static class CcuCommandGroup +{ + public const string Name = "ccu"; +} diff --git a/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupClientFactoryIntegrationTests.cs b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupClientFactoryIntegrationTests.cs new file mode 100644 index 0000000..8f4df3d --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupClientFactoryIntegrationTests.cs @@ -0,0 +1,185 @@ +using System.Net; +using System.Text; +using AwesomeAssertions; +using CreativeCoders.HomeMatic.FirmwareBackup; + +namespace CreativeCoders.HomeMatic.Tests.FirmwareBackup; + +public class FirmwareBackupClientFactoryIntegrationTests +{ + private const string FakeSessionId = "session-id-xyz"; + private static readonly byte[] BackupPayload = Encoding.UTF8.GetBytes("BACKUP-CONTENT"); + + [Fact] + public async Task CreateBackupAsync_HappyPath_PerformsLoginDownloadAndLogout() + { + // Arrange + var (factory, handler) = CreateFactory(); + handler.EnqueueJsonResponse($"{{\"version\":\"1.1\",\"result\":\"{FakeSessionId}\",\"error\":null}}"); + handler.EnqueueBinaryResponse(BackupPayload, "ccu_backup.sbk"); + handler.EnqueueJsonResponse("{\"version\":\"1.1\",\"result\":true,\"error\":null}"); + + var options = new FirmwareBackupOptions( + new Uri("https://ccu.example.local"), + new NetworkCredential("Admin", "secret")); + + var client = factory.Create(options); + + // Act + await using var result = await client.CreateBackupAsync(); + using var ms = new MemoryStream(); + await result.Content.CopyToAsync(ms); + + // Assert + ms.ToArray().Should().BeEquivalentTo(BackupPayload); + result.FileName.Should().Be("ccu_backup.sbk"); + + handler.Requests.Should().HaveCount(2); + handler.Requests[0].Uri.AbsolutePath.Should().Be("/api/homematic.cgi"); + handler.Requests[0].Body.Should().Contain("\"Session.login\"").And.Contain("\"Admin\""); + handler.Requests[1].Uri.AbsolutePath.Should().Be("/config/cp_security.cgi"); + handler.Requests[1].Method.Should().Be(HttpMethod.Get); + handler.Requests[1].Uri.Query.Should().Contain($"sid=%40{FakeSessionId}%40").And.Contain("action=create_backup"); + } + + [Fact] + public async Task CreateBackupAsync_HappyPath_LogoutCalledOnDispose() + { + // Arrange + var (factory, handler) = CreateFactory(); + handler.EnqueueJsonResponse($"{{\"result\":\"{FakeSessionId}\",\"error\":null}}"); + handler.EnqueueBinaryResponse(BackupPayload, "backup.sbk"); + handler.EnqueueJsonResponse("{\"result\":true,\"error\":null}"); + + var client = factory.Create(new FirmwareBackupOptions( + new Uri("https://ccu.example.local"), + new NetworkCredential("Admin", "x"))); + + // Act + var result = await client.CreateBackupAsync(); + await result.DisposeAsync(); + + // Assert + handler.Requests.Should().HaveCount(3); + handler.Requests[2].Body.Should().Contain("\"Session.logout\"").And.Contain(FakeSessionId); + } + + [Fact] + public async Task CreateBackupAsync_DownloadFails_LogoutStillCalled() + { + // Arrange + var (factory, handler) = CreateFactory(); + handler.EnqueueJsonResponse($"{{\"result\":\"{FakeSessionId}\",\"error\":null}}"); + handler.EnqueueResponse(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent("boom") + }); + handler.EnqueueJsonResponse("{\"result\":true,\"error\":null}"); + + var client = factory.Create(new FirmwareBackupOptions( + new Uri("https://ccu.example.local"), + new NetworkCredential("Admin", "x"))); + + // Act + var act = async () => await client.CreateBackupAsync(); + + // Assert + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.InternalServerError); + handler.Requests.Should().HaveCount(3); + handler.Requests[2].Body.Should().Contain("\"Session.logout\""); + } + + [Fact] + public async Task CreateBackupAsync_LoginFailsWithError_ThrowsFirmwareBackupException() + { + // Arrange + var (factory, handler) = CreateFactory(); + handler.EnqueueJsonResponse( + "{\"result\":null,\"error\":{\"code\":2,\"message\":\"invalid credentials\"}}"); + + var client = factory.Create(new FirmwareBackupOptions( + new Uri("https://ccu.example.local"), + new NetworkCredential("Admin", "wrong"))); + + // Act + var act = async () => await client.CreateBackupAsync(); + + // Assert + var ex = await act.Should().ThrowAsync(); + ex.Which.Message.Should().Contain("invalid credentials"); + } + + [Fact] + public async Task CreateBackupToFileAsync_WritesContentToFile() + { + // Arrange + var (factory, handler) = CreateFactory(); + handler.EnqueueJsonResponse($"{{\"result\":\"{FakeSessionId}\",\"error\":null}}"); + handler.EnqueueBinaryResponse(BackupPayload, "ccu_backup.sbk"); + handler.EnqueueJsonResponse("{\"result\":true,\"error\":null}"); + + var tempDir = Path.Combine(Path.GetTempPath(), "fwbackup-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + + try + { + var client = factory.Create(new FirmwareBackupOptions( + new Uri("https://ccu.example.local"), + new NetworkCredential("Admin", "x"))); + + // Act + var path = await client.CreateBackupToFileAsync(tempDir); + + // Assert + File.Exists(path).Should().BeTrue(); + (await File.ReadAllBytesAsync(path)).Should().BeEquivalentTo(BackupPayload); + Path.GetFileName(path).Should().Be("ccu_backup.sbk"); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task CreateBackupToFileAsync_WithExplicitFilePath_UsesGivenPath() + { + // Arrange + var (factory, handler) = CreateFactory(); + handler.EnqueueJsonResponse($"{{\"result\":\"{FakeSessionId}\",\"error\":null}}"); + handler.EnqueueBinaryResponse(BackupPayload, "default.sbk"); + handler.EnqueueJsonResponse("{\"result\":true,\"error\":null}"); + + var tempFile = Path.Combine(Path.GetTempPath(), "fwbackup-" + Guid.NewGuid().ToString("N") + ".sbk"); + + try + { + var client = factory.Create(new FirmwareBackupOptions( + new Uri("https://ccu.example.local"), + new NetworkCredential("Admin", "x"))); + + // Act + var path = await client.CreateBackupToFileAsync(tempFile); + + // Assert + path.Should().Be(tempFile); + (await File.ReadAllBytesAsync(path)).Should().BeEquivalentTo(BackupPayload); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + private static (IFirmwareBackupClientFactory Factory, QueueingHttpMessageHandler Handler) CreateFactory() + { + var handler = new QueueingHttpMessageHandler(); + var httpClientFactory = new SingleHandlerHttpClientFactory(handler); + var factory = new FirmwareBackupClientFactory(httpClientFactory); + return (factory, handler); + } +} diff --git a/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupOptionsTests.cs b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupOptionsTests.cs new file mode 100644 index 0000000..909c719 --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupOptionsTests.cs @@ -0,0 +1,54 @@ +using System.Net; +using AwesomeAssertions; +using CreativeCoders.HomeMatic.FirmwareBackup; + +namespace CreativeCoders.HomeMatic.Tests.FirmwareBackup; + +public class FirmwareBackupOptionsTests +{ + [Fact] + public void Constructor_WithValidArgs_SetsProperties() + { + // Arrange + var url = new Uri("https://ccu.example.local"); + var credential = new NetworkCredential("admin", "pwd"); + + // Act + var options = new FirmwareBackupOptions(url, credential); + + // Assert + options.BaseUrl.Should().BeSameAs(url); + options.Credential.Should().BeSameAs(credential); + options.JsonRpcPath.Should().Be("/api/homematic.cgi"); + options.BackupCgiPath.Should().Be("/config/cp_security.cgi"); + options.BackupAction.Should().Be("create_backup"); + options.AcceptAnyServerCertificate.Should().BeTrue(); + options.Timeout.Should().Be(TimeSpan.FromMinutes(5)); + } + + [Fact] + public void Constructor_WithNullUrl_Throws() + { + // Arrange + var credential = new NetworkCredential("admin", "pwd"); + + // Act + var act = () => new FirmwareBackupOptions(null!, credential); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Constructor_WithNullCredential_Throws() + { + // Arrange + var url = new Uri("https://ccu.example.local"); + + // Act + var act = () => new FirmwareBackupOptions(url, null!); + + // Assert + act.Should().Throw(); + } +} diff --git a/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupServiceCollectionExtensionsTests.cs b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..aa42a0d --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupServiceCollectionExtensionsTests.cs @@ -0,0 +1,53 @@ +using AwesomeAssertions; +using CreativeCoders.HomeMatic.FirmwareBackup; +using Microsoft.Extensions.DependencyInjection; + +namespace CreativeCoders.HomeMatic.Tests.FirmwareBackup; + +public class FirmwareBackupServiceCollectionExtensionsTests +{ + [Fact] + public void AddHomeMaticFirmwareBackup_RegistersFactory() + { + // Arrange + IServiceCollection services = new ServiceCollection(); + + // Act + services.AddHomeMaticFirmwareBackup(); + var sp = services.BuildServiceProvider(); + + // Assert + var factory = sp.GetRequiredService(); + factory.Should().BeOfType(); + } + + [Fact] + public void AddHomeMaticFirmwareBackup_RegistersHttpClientFactory() + { + // Arrange + IServiceCollection services = new ServiceCollection(); + + // Act + services.AddHomeMaticFirmwareBackup(); + var sp = services.BuildServiceProvider(); + + // Assert + var httpClientFactory = sp.GetRequiredService(); + var client = httpClientFactory.CreateClient(FirmwareBackupClientFactory.HttpClientName); + client.Should().NotBeNull(); + } + + [Fact] + public void AddHomeMatic_RegistersFirmwareBackupFactory() + { + // Arrange + IServiceCollection services = new ServiceCollection(); + + // Act + services.AddHomeMatic(); + var sp = services.BuildServiceProvider(); + + // Assert + sp.GetRequiredService().Should().NotBeNull(); + } +} diff --git a/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/QueueingHttpMessageHandler.cs b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/QueueingHttpMessageHandler.cs new file mode 100644 index 0000000..28008e4 --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/QueueingHttpMessageHandler.cs @@ -0,0 +1,64 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; + +namespace CreativeCoders.HomeMatic.Tests.FirmwareBackup; + +/// +/// Test that returns a queued response per request and records +/// the requests it receives. +/// +internal sealed class QueueingHttpMessageHandler : HttpMessageHandler +{ + private readonly Queue> _responders = new(); + + public List Requests { get; } = []; + + public void EnqueueJsonResponse(string json, HttpStatusCode statusCode = HttpStatusCode.OK) + { + _responders.Enqueue(_ => new HttpResponseMessage(statusCode) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }); + } + + public void EnqueueBinaryResponse(byte[] payload, string fileName, HttpStatusCode statusCode = HttpStatusCode.OK) + { + _responders.Enqueue(_ => + { + var content = new ByteArrayContent(payload); + content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + content.Headers.ContentDisposition = new ContentDispositionHeaderValue("attachment") + { + FileName = fileName + }; + + return new HttpResponseMessage(statusCode) { Content = content }; + }); + } + + public void EnqueueResponse(Func responder) + { + _responders.Enqueue(responder); + } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var body = request.Content is null + ? string.Empty + : await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + Requests.Add(new RecordedRequest(request.Method, request.RequestUri!, body)); + + if (_responders.Count == 0) + { + throw new InvalidOperationException("No more responses queued."); + } + + return _responders.Dequeue()(request); + } +} + +internal sealed record RecordedRequest(HttpMethod Method, Uri Uri, string Body); diff --git a/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/SingleHandlerHttpClientFactory.cs b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/SingleHandlerHttpClientFactory.cs new file mode 100644 index 0000000..1bbbead --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/SingleHandlerHttpClientFactory.cs @@ -0,0 +1,20 @@ +namespace CreativeCoders.HomeMatic.Tests.FirmwareBackup; + +/// +/// Minimal that returns a single backed by +/// the given . +/// +internal sealed class SingleHandlerHttpClientFactory : IHttpClientFactory +{ + private readonly HttpMessageHandler _handler; + + public SingleHandlerHttpClientFactory(HttpMessageHandler handler) + { + _handler = handler; + } + + public HttpClient CreateClient(string name) + { + return new HttpClient(_handler, disposeHandler: false); + } +} From 5841b147444ce9f0fc3723ba742065c5bce17359 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 26 Apr 2026 12:33:39 +0200 Subject: [PATCH 2/7] feat(cli): add `BackupCcuCommand` to perform CCU firmware backups - Implemented `BackupCcuCommand` to support creating firmware backups from configured CCUs. - Added `BackupCcuOptions` for specifying connection name and output directory. - Integrated with `ICcuConnectionsStore` and `IFirmwareBackupClientFactory` for connection management and backup handling. - Updated `.gitignore` to exclude CCU backup directories. --- .gitignore | 1 - .../Ccu/Backup/BackupCcuCommand.cs | 90 +++++++++++++++++++ .../Ccu/Backup/BackupCcuOptions.cs | 14 +++ 3 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/BackupCcuCommand.cs create mode 100644 source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/BackupCcuOptions.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/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/BackupCcuCommand.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/BackupCcuCommand.cs new file mode 100644 index 0000000..d24df9a --- /dev/null +++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/BackupCcuCommand.cs @@ -0,0 +1,90 @@ +using CreativeCoders.Cli.Core; +using CreativeCoders.Core; +using CreativeCoders.HomeMatic.FirmwareBackup; +using CreativeCoders.HomeMatic.Tools.Cli.Base.Connections; +using JetBrains.Annotations; +using Spectre.Console; + +namespace CreativeCoders.HomeMatic.Tools.Cli.Commands.Ccu.Backup; + +[UsedImplicitly] +[CliCommand([CcuCommandGroup.Name, "backup"], Description = "Create a firmware backup of a configured CCU")] +public class BackupCcuCommand( + IAnsiConsole console, + ICcuConnectionsStore ccuConnectionsStore, + IFirmwareBackupClientFactory firmwareBackupClientFactory) + : ICliCommand +{ + private readonly IAnsiConsole _console = Ensure.NotNull(console); + + private readonly ICcuConnectionsStore _ccuConnectionsStore = Ensure.NotNull(ccuConnectionsStore); + + private readonly IFirmwareBackupClientFactory _firmwareBackupClientFactory = + Ensure.NotNull(firmwareBackupClientFactory); + + public async Task ExecuteAsync(BackupCcuOptions options) + { + if (string.IsNullOrWhiteSpace(options.Name)) + { + _console.MarkupLine("[bold italic red3]A CCU connection name is required.[/]"); + return -1; + } + + var connections = await _ccuConnectionsStore.GetConnectionsAsync().ConfigureAwait(false); + + var connection = connections + .FirstOrDefault(x => string.Equals(x.Name, options.Name, StringComparison.OrdinalIgnoreCase)); + + if (connection is null) + { + _console.MarkupLine( + $"[bold italic red3]No CCU connection named '{Markup.Escape(options.Name)}' found.[/]"); + return -1; + } + + var credential = _ccuConnectionsStore.GetCredentials(connection); + + var backupOptions = new FirmwareBackupOptions(connection.Url, credential); + var client = _firmwareBackupClientFactory.Create(backupOptions); + + var outputDirectory = string.IsNullOrWhiteSpace(options.OutputDirectory) + ? Environment.CurrentDirectory + : options.OutputDirectory; + + Directory.CreateDirectory(outputDirectory); + + var fileName = BuildBackupFileName(connection.Name); + var targetPath = Path.Combine(outputDirectory, fileName); + + _console.MarkupLine( + $"Creating firmware backup of CCU [bold]{Markup.Escape(connection.Name)}[/] " + + $"([italic]{Markup.Escape(connection.Url.ToString())}[/])..."); + + try + { + var writtenPath = await client.CreateBackupToFileAsync(targetPath).ConfigureAwait(false); + + _console.MarkupLine($"[bold lime]Backup created:[/] {Markup.Escape(writtenPath)}"); + return CommandResult.Success; + } + catch (FirmwareBackupException ex) + { + _console.MarkupLine($"[bold italic red3]Backup failed: {Markup.Escape(ex.Message)}[/]"); + return -1; + } + } + + private static string BuildBackupFileName(string ccuName) + { + var safeName = MakeFileNameSafe(ccuName); + var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); + return $"{safeName}_{timestamp}.sbk"; + } + + private static string MakeFileNameSafe(string value) + { + var invalid = Path.GetInvalidFileNameChars(); + var sanitized = new string(value.Select(c => invalid.Contains(c) ? '_' : c).ToArray()); + return string.IsNullOrWhiteSpace(sanitized) ? "ccu" : sanitized; + } +} diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/BackupCcuOptions.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/BackupCcuOptions.cs new file mode 100644 index 0000000..670951c --- /dev/null +++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/BackupCcuOptions.cs @@ -0,0 +1,14 @@ +using CreativeCoders.SysConsole.Cli.Parsing; +using JetBrains.Annotations; + +namespace CreativeCoders.HomeMatic.Tools.Cli.Commands.Ccu.Backup; + +[UsedImplicitly] +public class BackupCcuOptions +{ + [OptionValue(0, IsRequired = true, HelpText = "Name of the configured CCU connection")] + public string Name { get; set; } = string.Empty; + + [OptionParameter('o', "output", HelpText = "Output directory for the backup file (default: current directory)")] + public string OutputDirectory { get; set; } = string.Empty; +} From a55d0bdcd12043460b03be905ee12d8f6d44e035 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 26 Apr 2026 13:07:00 +0200 Subject: [PATCH 3/7] feat(homeMatic): improve firmware backup handling and CLI usability - Refactored `FirmwareBackupClient` to enhance resource management and file handling. - Updated `BackupCcuCommand` to validate output file paths and handle target directory creation. - Replaced `OutputDirectory` with `OutputFile` in `BackupCcuOptions` for precise path control. - Integrated `FileSystem` into services for consistent file operations. - Simplified and streamlined backup file path resolution logic. --- .../FirmwareBackup/FirmwareBackupClient.cs | 18 +++++----- .../FirmwareBackup/FirmwareBackupResult.cs | 2 +- ...rmwareBackupServiceCollectionExtensions.cs | 2 ++ .../Internal/CcuSessionClient.cs | 15 ++++----- .../Internal/FirmwareBackupDownloader.cs | 12 +++---- .../Ccu/Backup/BackupCcuCommand.cs | 33 +++++++------------ .../Ccu/Backup/BackupCcuOptions.cs | 5 +-- .../Program.cs | 2 ++ 8 files changed, 41 insertions(+), 48 deletions(-) diff --git a/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupClient.cs b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupClient.cs index 0d85a5e..80b34e1 100644 --- a/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupClient.cs +++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupClient.cs @@ -1,4 +1,5 @@ using CreativeCoders.Core; +using CreativeCoders.Core.IO; using CreativeCoders.HomeMatic.FirmwareBackup.Internal; namespace CreativeCoders.HomeMatic.FirmwareBackup; @@ -49,11 +50,13 @@ public async Task CreateBackupAsync(CancellationToken canc } /// - public async Task CreateBackupToFileAsync(string targetFilePath, CancellationToken cancellationToken = default) + public async Task CreateBackupToFileAsync(string targetFilePath, + CancellationToken cancellationToken = default) { Ensure.IsNotNullOrWhitespace(targetFilePath); - await using var backup = await CreateBackupAsync(cancellationToken).ConfigureAwait(false); + var backup = await CreateBackupAsync(cancellationToken).ConfigureAwait(false); + await using var backup1 = backup.ConfigureAwait(false); var resolvedPath = ResolveFilePath(targetFilePath, backup.FileName); @@ -63,7 +66,8 @@ public async Task CreateBackupToFileAsync(string targetFilePath, Cancell Directory.CreateDirectory(directory); } - await using var fileStream = File.Create(resolvedPath); + var fileStream = FileSys.File.Create(resolvedPath); + await using var stream = fileStream.ConfigureAwait(false); await backup.Content.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); return resolvedPath; @@ -71,12 +75,8 @@ public async Task CreateBackupToFileAsync(string targetFilePath, Cancell private static string ResolveFilePath(string targetFilePath, string suggestedFileName) { - if (Directory.Exists(targetFilePath)) - { - return Path.Combine(targetFilePath, suggestedFileName); - } - - if (targetFilePath.EndsWith(Path.DirectorySeparatorChar) || + if (Directory.Exists(targetFilePath) || + targetFilePath.EndsWith(Path.DirectorySeparatorChar) || targetFilePath.EndsWith(Path.AltDirectorySeparatorChar)) { return Path.Combine(targetFilePath, suggestedFileName); diff --git a/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupResult.cs b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupResult.cs index f7d1a3e..67e016a 100644 --- a/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupResult.cs +++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupResult.cs @@ -34,7 +34,7 @@ public FirmwareBackupResult( Content = Ensure.NotNull(content); FileName = Ensure.IsNotNullOrWhitespace(fileName); ContentLength = contentLength; - _additionalResources = additionalResources ?? []; + _additionalResources = Ensure.NotNull(additionalResources); } /// diff --git a/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupServiceCollectionExtensions.cs b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupServiceCollectionExtensions.cs index fe98c4f..206a177 100644 --- a/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupServiceCollectionExtensions.cs +++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupServiceCollectionExtensions.cs @@ -1,3 +1,4 @@ +using CreativeCoders.Core.IO; using JetBrains.Annotations; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -18,6 +19,7 @@ public static class FirmwareBackupServiceCollectionExtensions /// The same instance to allow chaining calls. public static IServiceCollection AddHomeMaticFirmwareBackup(this IServiceCollection services) { + services.AddFileSystem(); services .AddHttpClient(FirmwareBackupClientFactory.HttpClientName) .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler diff --git a/source/CreativeCoders.HomeMatic/FirmwareBackup/Internal/CcuSessionClient.cs b/source/CreativeCoders.HomeMatic/FirmwareBackup/Internal/CcuSessionClient.cs index 94feb25..496dc26 100644 --- a/source/CreativeCoders.HomeMatic/FirmwareBackup/Internal/CcuSessionClient.cs +++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/Internal/CcuSessionClient.cs @@ -1,4 +1,3 @@ -using System.Net.Http.Headers; using System.Text; using System.Text.Json; using CreativeCoders.Core; @@ -23,7 +22,8 @@ public CcuSessionClient(HttpClient httpClient, Uri baseUrl, string jsonRpcPath) _jsonRpcUrl = new Uri(baseUrl, jsonRpcPath); } - public async Task LoginAsync(string userName, string password, CancellationToken cancellationToken = default) + public async Task LoginAsync(string userName, string password, + CancellationToken cancellationToken = default) { Ensure.IsNotNullOrWhitespace(userName); Ensure.NotNull(password); @@ -45,7 +45,7 @@ public async Task LoginAsync(string userName, string password, Cancellat "CCU did not return a session id. Please verify the credentials."); } - return sessionId!; + return sessionId; } public async Task LogoutAsync(string sessionId, CancellationToken cancellationToken = default) @@ -76,10 +76,8 @@ private async Task PostJsonRpcAsync(object payload, CancellationToken ca { var json = JsonSerializer.Serialize(payload); - using var request = new HttpRequestMessage(HttpMethod.Post, _jsonRpcUrl) - { - Content = new StringContent(json, Encoding.UTF8, "application/json") - }; + using var request = new HttpRequestMessage(HttpMethod.Post, _jsonRpcUrl); + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); @@ -103,7 +101,8 @@ private async Task PostJsonRpcAsync(object payload, CancellationToken ca if (root.TryGetProperty("error", out var errorElement) && errorElement.ValueKind != JsonValueKind.Null) { - var message = errorElement.TryGetProperty("message", out var msgEl) && msgEl.ValueKind == JsonValueKind.String + var message = errorElement.TryGetProperty("message", out var msgEl) && + msgEl.ValueKind == JsonValueKind.String ? msgEl.GetString() : errorElement.ToString(); diff --git a/source/CreativeCoders.HomeMatic/FirmwareBackup/Internal/FirmwareBackupDownloader.cs b/source/CreativeCoders.HomeMatic/FirmwareBackup/Internal/FirmwareBackupDownloader.cs index d7f834e..a0e5cf9 100644 --- a/source/CreativeCoders.HomeMatic/FirmwareBackup/Internal/FirmwareBackupDownloader.cs +++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/Internal/FirmwareBackupDownloader.cs @@ -23,7 +23,8 @@ public FirmwareBackupDownloader(HttpClient httpClient, Uri baseUrl, string backu _backupAction = Ensure.IsNotNullOrWhitespace(backupAction); } - public async Task DownloadAsync(string sessionId, CancellationToken cancellationToken = default) + public async Task DownloadAsync(string sessionId, + CancellationToken cancellationToken = default) { Ensure.IsNotNullOrWhitespace(sessionId); @@ -66,12 +67,9 @@ private static string ResolveFileName(HttpResponseMessage response) var fileName = disposition?.FileNameStar ?? disposition?.FileName; - if (string.IsNullOrWhiteSpace(fileName)) - { - return DefaultFileName; - } - - return fileName!.Trim('"'); + return string.IsNullOrWhiteSpace(fileName) + ? DefaultFileName + : fileName.Trim('"'); } private Uri BuildRequestUri(string sessionId) diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/BackupCcuCommand.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/BackupCcuCommand.cs index d24df9a..2f10316 100644 --- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/BackupCcuCommand.cs +++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/BackupCcuCommand.cs @@ -30,6 +30,12 @@ public async Task ExecuteAsync(BackupCcuOptions options) return -1; } + if (string.IsNullOrWhiteSpace(options.OutputFile)) + { + _console.MarkupLine("[bold italic red3]An output file path is required.[/]"); + return -1; + } + var connections = await _ccuConnectionsStore.GetConnectionsAsync().ConfigureAwait(false); var connection = connections @@ -47,14 +53,13 @@ public async Task ExecuteAsync(BackupCcuOptions options) var backupOptions = new FirmwareBackupOptions(connection.Url, credential); var client = _firmwareBackupClientFactory.Create(backupOptions); - var outputDirectory = string.IsNullOrWhiteSpace(options.OutputDirectory) - ? Environment.CurrentDirectory - : options.OutputDirectory; + var targetPath = Path.GetFullPath(options.OutputFile); + var targetDirectory = Path.GetDirectoryName(targetPath); - Directory.CreateDirectory(outputDirectory); - - var fileName = BuildBackupFileName(connection.Name); - var targetPath = Path.Combine(outputDirectory, fileName); + if (!string.IsNullOrEmpty(targetDirectory)) + { + Directory.CreateDirectory(targetDirectory); + } _console.MarkupLine( $"Creating firmware backup of CCU [bold]{Markup.Escape(connection.Name)}[/] " + @@ -73,18 +78,4 @@ public async Task ExecuteAsync(BackupCcuOptions options) return -1; } } - - private static string BuildBackupFileName(string ccuName) - { - var safeName = MakeFileNameSafe(ccuName); - var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); - return $"{safeName}_{timestamp}.sbk"; - } - - private static string MakeFileNameSafe(string value) - { - var invalid = Path.GetInvalidFileNameChars(); - var sanitized = new string(value.Select(c => invalid.Contains(c) ? '_' : c).ToArray()); - return string.IsNullOrWhiteSpace(sanitized) ? "ccu" : sanitized; - } } diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/BackupCcuOptions.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/BackupCcuOptions.cs index 670951c..5b5b0e0 100644 --- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/BackupCcuOptions.cs +++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/BackupCcuOptions.cs @@ -9,6 +9,7 @@ public class BackupCcuOptions [OptionValue(0, IsRequired = true, HelpText = "Name of the configured CCU connection")] public string Name { get; set; } = string.Empty; - [OptionParameter('o', "output", HelpText = "Output directory for the backup file (default: current directory)")] - public string OutputDirectory { get; set; } = string.Empty; + [OptionParameter('o', "output", IsRequired = true, + HelpText = "Path of the backup file to create")] + public string OutputFile { get; set; } = string.Empty; } diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Hmc/Program.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Hmc/Program.cs index 386dda9..f1d5ec4 100644 --- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Hmc/Program.cs +++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Hmc/Program.cs @@ -42,5 +42,7 @@ private static void ConfigureServices(IServiceCollection services) services.AddHomeMaticXmlRpc(); services.AddHomeMaticJsonRpc(); + + services.AddFileSystem(); } } From c4fba9c44091202b7ead75a3cd6b28ef9f5568f8 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 26 Apr 2026 13:13:57 +0200 Subject: [PATCH 4/7] feat(homeMatic): integrate `IFileSystem` for file operations in firmware backup workflow - Replaced `System.IO` usage with `IFileSystem` abstraction across `FirmwareBackupClient`, `BackupCcuCommand`, and related components. - Updated `FirmwareBackupClientFactory` and tests to support `IFileSystem` injection. - Ensured consistent and testable file handling logic throughout the firmware backup pipeline. --- .../FirmwareBackup/FirmwareBackupClient.cs | 23 +++++++++++-------- .../FirmwareBackupClientFactory.cs | 8 +++++-- .../Ccu/Backup/BackupCcuCommand.cs | 12 ++++++---- ...wareBackupClientFactoryIntegrationTests.cs | 9 +++++++- 4 files changed, 35 insertions(+), 17 deletions(-) diff --git a/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupClient.cs b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupClient.cs index 80b34e1..1d18648 100644 --- a/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupClient.cs +++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupClient.cs @@ -1,6 +1,6 @@ using CreativeCoders.Core; -using CreativeCoders.Core.IO; using CreativeCoders.HomeMatic.FirmwareBackup.Internal; +using System.IO.Abstractions; namespace CreativeCoders.HomeMatic.FirmwareBackup; @@ -13,15 +13,18 @@ public sealed class FirmwareBackupClient : IFirmwareBackupClient private readonly ICcuSessionClient _sessionClient; private readonly IFirmwareBackupDownloader _downloader; private readonly FirmwareBackupOptions _options; + private readonly IFileSystem _fileSystem; internal FirmwareBackupClient( ICcuSessionClient sessionClient, IFirmwareBackupDownloader downloader, - FirmwareBackupOptions options) + FirmwareBackupOptions options, + IFileSystem fileSystem) { _sessionClient = Ensure.NotNull(sessionClient); _downloader = Ensure.NotNull(downloader); _options = Ensure.NotNull(options); + _fileSystem = Ensure.NotNull(fileSystem); } /// @@ -60,26 +63,26 @@ public async Task CreateBackupToFileAsync(string targetFilePath, var resolvedPath = ResolveFilePath(targetFilePath, backup.FileName); - var directory = Path.GetDirectoryName(resolvedPath); + var directory = _fileSystem.Path.GetDirectoryName(resolvedPath); if (!string.IsNullOrWhiteSpace(directory)) { - Directory.CreateDirectory(directory); + _fileSystem.Directory.CreateDirectory(directory); } - var fileStream = FileSys.File.Create(resolvedPath); + var fileStream = _fileSystem.File.Create(resolvedPath); await using var stream = fileStream.ConfigureAwait(false); await backup.Content.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); return resolvedPath; } - private static string ResolveFilePath(string targetFilePath, string suggestedFileName) + private string ResolveFilePath(string targetFilePath, string suggestedFileName) { - if (Directory.Exists(targetFilePath) || - targetFilePath.EndsWith(Path.DirectorySeparatorChar) || - targetFilePath.EndsWith(Path.AltDirectorySeparatorChar)) + if (_fileSystem.Directory.Exists(targetFilePath) || + targetFilePath.EndsWith(_fileSystem.Path.DirectorySeparatorChar) || + targetFilePath.EndsWith(_fileSystem.Path.AltDirectorySeparatorChar)) { - return Path.Combine(targetFilePath, suggestedFileName); + return _fileSystem.Path.Combine(targetFilePath, suggestedFileName); } return targetFilePath; diff --git a/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupClientFactory.cs b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupClientFactory.cs index 345921a..d3dec72 100644 --- a/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupClientFactory.cs +++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupClientFactory.cs @@ -1,5 +1,6 @@ using CreativeCoders.Core; using CreativeCoders.HomeMatic.FirmwareBackup.Internal; +using System.IO.Abstractions; namespace CreativeCoders.HomeMatic.FirmwareBackup; @@ -16,14 +17,17 @@ public sealed class FirmwareBackupClientFactory : IFirmwareBackupClientFactory public const string HttpClientName = "CreativeCoders.HomeMatic.FirmwareBackup"; private readonly IHttpClientFactory _httpClientFactory; + private readonly IFileSystem _fileSystem; /// /// Initializes a new instance of . /// /// Factory used to obtain the named HTTP client. - public FirmwareBackupClientFactory(IHttpClientFactory httpClientFactory) + /// File system abstraction used by created clients. + public FirmwareBackupClientFactory(IHttpClientFactory httpClientFactory, IFileSystem fileSystem) { _httpClientFactory = Ensure.NotNull(httpClientFactory); + _fileSystem = Ensure.NotNull(fileSystem); } /// @@ -41,6 +45,6 @@ public IFirmwareBackupClient Create(FirmwareBackupOptions options) options.BackupCgiPath, options.BackupAction); - return new FirmwareBackupClient(sessionClient, downloader, options); + return new FirmwareBackupClient(sessionClient, downloader, options, _fileSystem); } } diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/BackupCcuCommand.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/BackupCcuCommand.cs index 2f10316..873d445 100644 --- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/BackupCcuCommand.cs +++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/BackupCcuCommand.cs @@ -4,6 +4,7 @@ using CreativeCoders.HomeMatic.Tools.Cli.Base.Connections; using JetBrains.Annotations; using Spectre.Console; +using System.IO.Abstractions; namespace CreativeCoders.HomeMatic.Tools.Cli.Commands.Ccu.Backup; @@ -12,7 +13,8 @@ namespace CreativeCoders.HomeMatic.Tools.Cli.Commands.Ccu.Backup; public class BackupCcuCommand( IAnsiConsole console, ICcuConnectionsStore ccuConnectionsStore, - IFirmwareBackupClientFactory firmwareBackupClientFactory) + IFirmwareBackupClientFactory firmwareBackupClientFactory, + IFileSystem fileSystem) : ICliCommand { private readonly IAnsiConsole _console = Ensure.NotNull(console); @@ -22,6 +24,8 @@ public class BackupCcuCommand( private readonly IFirmwareBackupClientFactory _firmwareBackupClientFactory = Ensure.NotNull(firmwareBackupClientFactory); + private readonly IFileSystem _fileSystem = Ensure.NotNull(fileSystem); + public async Task ExecuteAsync(BackupCcuOptions options) { if (string.IsNullOrWhiteSpace(options.Name)) @@ -53,12 +57,12 @@ public async Task ExecuteAsync(BackupCcuOptions options) var backupOptions = new FirmwareBackupOptions(connection.Url, credential); var client = _firmwareBackupClientFactory.Create(backupOptions); - var targetPath = Path.GetFullPath(options.OutputFile); - var targetDirectory = Path.GetDirectoryName(targetPath); + var targetPath = _fileSystem.Path.GetFullPath(options.OutputFile); + var targetDirectory = _fileSystem.Path.GetDirectoryName(targetPath); if (!string.IsNullOrEmpty(targetDirectory)) { - Directory.CreateDirectory(targetDirectory); + _fileSystem.Directory.CreateDirectory(targetDirectory); } _console.MarkupLine( diff --git a/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupClientFactoryIntegrationTests.cs b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupClientFactoryIntegrationTests.cs index 8f4df3d..4908ce7 100644 --- a/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupClientFactoryIntegrationTests.cs +++ b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupClientFactoryIntegrationTests.cs @@ -1,7 +1,10 @@ +using System.IO.Abstractions; using System.Net; using System.Text; using AwesomeAssertions; +using CreativeCoders.Core.IO; using CreativeCoders.HomeMatic.FirmwareBackup; +using Microsoft.Extensions.DependencyInjection; namespace CreativeCoders.HomeMatic.Tests.FirmwareBackup; @@ -179,7 +182,11 @@ private static (IFirmwareBackupClientFactory Factory, QueueingHttpMessageHandler { var handler = new QueueingHttpMessageHandler(); var httpClientFactory = new SingleHandlerHttpClientFactory(handler); - var factory = new FirmwareBackupClientFactory(httpClientFactory); + var fileSystem = new ServiceCollection() + .AddFileSystem() + .BuildServiceProvider() + .GetRequiredService(); + var factory = new FirmwareBackupClientFactory(httpClientFactory, fileSystem); return (factory, handler); } } From 1e6c019e65e66836f3079514084878049967cf34 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 26 Apr 2026 13:27:50 +0200 Subject: [PATCH 5/7] refactor(homeMatic): improve test readability and update annotations - Replaced `Encoding.UTF8.GetBytes` with `u8.ToArray` for concise byte array creation in tests. - Updated `BackupCcuOptions` annotation from `[UsedImplicitly]` to `[PublicAPI]` for better tooling support. - Simplified `SingleHandlerHttpClientFactory` by converting to a C# primary constructor. - Refactored conditional logic in `QueueingHttpMessageHandler` for cleaner exception handling. --- .../Ccu/Backup/BackupCcuOptions.cs | 2 +- .../FirmwareBackupClientFactoryIntegrationTests.cs | 5 +++-- .../FirmwareBackup/QueueingHttpMessageHandler.cs | 9 +++------ .../FirmwareBackup/SingleHandlerHttpClientFactory.cs | 11 ++--------- 4 files changed, 9 insertions(+), 18 deletions(-) diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/BackupCcuOptions.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/BackupCcuOptions.cs index 5b5b0e0..715a7ac 100644 --- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/BackupCcuOptions.cs +++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/BackupCcuOptions.cs @@ -3,7 +3,7 @@ namespace CreativeCoders.HomeMatic.Tools.Cli.Commands.Ccu.Backup; -[UsedImplicitly] +[PublicAPI] public class BackupCcuOptions { [OptionValue(0, IsRequired = true, HelpText = "Name of the configured CCU connection")] diff --git a/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupClientFactoryIntegrationTests.cs b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupClientFactoryIntegrationTests.cs index 4908ce7..240e2a0 100644 --- a/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupClientFactoryIntegrationTests.cs +++ b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupClientFactoryIntegrationTests.cs @@ -11,7 +11,7 @@ namespace CreativeCoders.HomeMatic.Tests.FirmwareBackup; public class FirmwareBackupClientFactoryIntegrationTests { private const string FakeSessionId = "session-id-xyz"; - private static readonly byte[] BackupPayload = Encoding.UTF8.GetBytes("BACKUP-CONTENT"); + private static readonly byte[] BackupPayload = "BACKUP-CONTENT"u8.ToArray(); [Fact] public async Task CreateBackupAsync_HappyPath_PerformsLoginDownloadAndLogout() @@ -42,7 +42,8 @@ public async Task CreateBackupAsync_HappyPath_PerformsLoginDownloadAndLogout() handler.Requests[0].Body.Should().Contain("\"Session.login\"").And.Contain("\"Admin\""); handler.Requests[1].Uri.AbsolutePath.Should().Be("/config/cp_security.cgi"); handler.Requests[1].Method.Should().Be(HttpMethod.Get); - handler.Requests[1].Uri.Query.Should().Contain($"sid=%40{FakeSessionId}%40").And.Contain("action=create_backup"); + handler.Requests[1].Uri.Query.Should().Contain($"sid=%40{FakeSessionId}%40").And + .Contain("action=create_backup"); } [Fact] diff --git a/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/QueueingHttpMessageHandler.cs b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/QueueingHttpMessageHandler.cs index 28008e4..e28f8b3 100644 --- a/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/QueueingHttpMessageHandler.cs +++ b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/QueueingHttpMessageHandler.cs @@ -52,12 +52,9 @@ protected override async Task SendAsync( Requests.Add(new RecordedRequest(request.Method, request.RequestUri!, body)); - if (_responders.Count == 0) - { - throw new InvalidOperationException("No more responses queued."); - } - - return _responders.Dequeue()(request); + return _responders.Count == 0 + ? throw new InvalidOperationException("No more responses queued.") + : _responders.Dequeue()(request); } } diff --git a/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/SingleHandlerHttpClientFactory.cs b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/SingleHandlerHttpClientFactory.cs index 1bbbead..6ac4850 100644 --- a/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/SingleHandlerHttpClientFactory.cs +++ b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/SingleHandlerHttpClientFactory.cs @@ -4,17 +4,10 @@ namespace CreativeCoders.HomeMatic.Tests.FirmwareBackup; /// Minimal that returns a single backed by /// the given . /// -internal sealed class SingleHandlerHttpClientFactory : IHttpClientFactory +internal sealed class SingleHandlerHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory { - private readonly HttpMessageHandler _handler; - - public SingleHandlerHttpClientFactory(HttpMessageHandler handler) - { - _handler = handler; - } - public HttpClient CreateClient(string name) { - return new HttpClient(_handler, disposeHandler: false); + return new HttpClient(handler, disposeHandler: false); } } From 82193d5b6c4d17b5a09109a8e9e30e5abb36f6cc Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 26 Apr 2026 15:20:12 +0200 Subject: [PATCH 6/7] feat(homeMatic): add support for custom server certificate handling in firmware backup - Introduced `HttpClient` with `AcceptAnyCertificate` option for self-signed certificates in `FirmwareBackupClientFactory`. - Updated DI registration in `AddHomeMaticFirmwareBackup` to configure both secure and bypass `HttpClient` instances. - Added unit tests to validate proper client creation based on `AcceptAnyServerCertificate` flag. --- .../FirmwareBackupClientFactory.cs | 16 +++- ...rmwareBackupServiceCollectionExtensions.cs | 9 ++- .../FirmwareBackupClientFactoryTests.cs | 73 +++++++++++++++++ ...eBackupServiceCollectionExtensionsTests.cs | 79 +++++++++++++++++++ 4 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupClientFactoryTests.cs diff --git a/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupClientFactory.cs b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupClientFactory.cs index d3dec72..d76c9c3 100644 --- a/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupClientFactory.cs +++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupClientFactory.cs @@ -12,10 +12,18 @@ namespace CreativeCoders.HomeMatic.FirmwareBackup; public sealed class FirmwareBackupClientFactory : IFirmwareBackupClientFactory { /// - /// Name of the named registered for firmware backup operations. + /// Name of the named registered for firmware backup operations + /// using the platform's standard server certificate validation. /// public const string HttpClientName = "CreativeCoders.HomeMatic.FirmwareBackup"; + /// + /// Name of the named registered for firmware backup operations + /// that accepts any (including self-signed) server certificate. + /// + public const string HttpClientNameAcceptAnyCertificate = + HttpClientName + ".AcceptAnyCertificate"; + private readonly IHttpClientFactory _httpClientFactory; private readonly IFileSystem _fileSystem; @@ -35,7 +43,11 @@ public IFirmwareBackupClient Create(FirmwareBackupOptions options) { Ensure.NotNull(options); - var httpClient = _httpClientFactory.CreateClient(HttpClientName); + var clientName = options.AcceptAnyServerCertificate + ? HttpClientNameAcceptAnyCertificate + : HttpClientName; + + var httpClient = _httpClientFactory.CreateClient(clientName); httpClient.Timeout = options.Timeout; var sessionClient = new CcuSessionClient(httpClient, options.BaseUrl, options.JsonRpcPath); diff --git a/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupServiceCollectionExtensions.cs b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupServiceCollectionExtensions.cs index 206a177..d6fab56 100644 --- a/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupServiceCollectionExtensions.cs +++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupServiceCollectionExtensions.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using CreativeCoders.Core.IO; using JetBrains.Annotations; using Microsoft.Extensions.DependencyInjection; @@ -17,11 +18,17 @@ public static class FirmwareBackupServiceCollectionExtensions /// /// The service collection to register the services on. /// The same instance to allow chaining calls. + [SuppressMessage("csharpsquid", "S4830:Server certificate validation should not be disabled", + Justification = + "Only used for explicitly opting in to accepting any server certificate, e.g. for self-signed certificates on a CCU.")] public static IServiceCollection AddHomeMaticFirmwareBackup(this IServiceCollection services) { services.AddFileSystem(); + + services.AddHttpClient(FirmwareBackupClientFactory.HttpClientName); + services - .AddHttpClient(FirmwareBackupClientFactory.HttpClientName) + .AddHttpClient(FirmwareBackupClientFactory.HttpClientNameAcceptAnyCertificate) .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { ServerCertificateCustomValidationCallback = (_, _, _, _) => true diff --git a/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupClientFactoryTests.cs b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupClientFactoryTests.cs new file mode 100644 index 0000000..b691a43 --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupClientFactoryTests.cs @@ -0,0 +1,73 @@ +using System.IO.Abstractions; +using System.Net; +using AwesomeAssertions; +using CreativeCoders.Core.IO; +using CreativeCoders.HomeMatic.FirmwareBackup; +using FakeItEasy; +using Microsoft.Extensions.DependencyInjection; + +namespace CreativeCoders.HomeMatic.Tests.FirmwareBackup; + +public class FirmwareBackupClientFactoryTests +{ + [Fact] + public void Create_WithAcceptAnyServerCertificateTrue_UsesAcceptAnyHttpClient() + { + // Arrange + var (factory, httpClientFactory) = CreateSut(); + var options = new FirmwareBackupOptions( + new Uri("https://ccu.local"), + new NetworkCredential("user", "pass")) + { + AcceptAnyServerCertificate = true + }; + + // Act + factory.Create(options); + + // Assert + A.CallTo(() => httpClientFactory.CreateClient( + FirmwareBackupClientFactory.HttpClientNameAcceptAnyCertificate)) + .MustHaveHappenedOnceExactly(); + A.CallTo(() => httpClientFactory.CreateClient(FirmwareBackupClientFactory.HttpClientName)) + .MustNotHaveHappened(); + } + + [Fact] + public void Create_WithAcceptAnyServerCertificateFalse_UsesDefaultHttpClient() + { + // Arrange + var (factory, httpClientFactory) = CreateSut(); + var options = new FirmwareBackupOptions( + new Uri("https://ccu.local"), + new NetworkCredential("user", "pass")) + { + AcceptAnyServerCertificate = false + }; + + // Act + factory.Create(options); + + // Assert + A.CallTo(() => httpClientFactory.CreateClient(FirmwareBackupClientFactory.HttpClientName)) + .MustHaveHappenedOnceExactly(); + A.CallTo(() => httpClientFactory.CreateClient( + FirmwareBackupClientFactory.HttpClientNameAcceptAnyCertificate)) + .MustNotHaveHappened(); + } + + private static (FirmwareBackupClientFactory Factory, IHttpClientFactory HttpClientFactory) CreateSut() + { + var httpClientFactory = A.Fake(); + A.CallTo(() => httpClientFactory.CreateClient(A._)) + .ReturnsLazily(() => new HttpClient(new QueueingHttpMessageHandler(), disposeHandler: false)); + + var fileSystem = new ServiceCollection() + .AddFileSystem() + .BuildServiceProvider() + .GetRequiredService(); + + var factory = new FirmwareBackupClientFactory(httpClientFactory, fileSystem); + return (factory, httpClientFactory); + } +} diff --git a/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupServiceCollectionExtensionsTests.cs b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupServiceCollectionExtensionsTests.cs index aa42a0d..05aec79 100644 --- a/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupServiceCollectionExtensionsTests.cs +++ b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupServiceCollectionExtensionsTests.cs @@ -1,6 +1,10 @@ using AwesomeAssertions; using CreativeCoders.HomeMatic.FirmwareBackup; +using FakeItEasy; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Http; +using Microsoft.Extensions.Options; namespace CreativeCoders.HomeMatic.Tests.FirmwareBackup; @@ -37,6 +41,62 @@ public void AddHomeMaticFirmwareBackup_RegistersHttpClientFactory() client.Should().NotBeNull(); } + [Fact] + public void AddHomeMaticFirmwareBackup_RegistersAcceptAnyCertificateHttpClient() + { + // Arrange + IServiceCollection services = new ServiceCollection(); + + // Act + services.AddHomeMaticFirmwareBackup(); + var sp = services.BuildServiceProvider(); + + // Assert + var httpClientFactory = sp.GetRequiredService(); + var client = httpClientFactory.CreateClient( + FirmwareBackupClientFactory.HttpClientNameAcceptAnyCertificate); + client.Should().NotBeNull(); + } + + [Fact] + public void AddHomeMaticFirmwareBackup_AcceptAnyCertificateClient_ConfiguresBypassCallback() + { + // Arrange + IServiceCollection services = new ServiceCollection(); + services.AddHomeMaticFirmwareBackup(); + var sp = services.BuildServiceProvider(); + + // Act + var primaryHandler = BuildPrimaryHandler( + sp, + FirmwareBackupClientFactory.HttpClientNameAcceptAnyCertificate); + + // Assert + primaryHandler.Should().BeOfType(); + ((HttpClientHandler)primaryHandler).ServerCertificateCustomValidationCallback + .Should().NotBeNull(); + } + + [Fact] + public void AddHomeMaticFirmwareBackup_DefaultClient_DoesNotBypassCertificate() + { + // Arrange + IServiceCollection services = new ServiceCollection(); + services.AddHomeMaticFirmwareBackup(); + var sp = services.BuildServiceProvider(); + + // Act + var primaryHandler = BuildPrimaryHandler( + sp, + FirmwareBackupClientFactory.HttpClientName); + + // Assert + if (primaryHandler is HttpClientHandler httpClientHandler) + { + httpClientHandler.ServerCertificateCustomValidationCallback.Should().BeNull(); + } + } + [Fact] public void AddHomeMatic_RegistersFirmwareBackupFactory() { @@ -50,4 +110,23 @@ public void AddHomeMatic_RegistersFirmwareBackupFactory() // Assert sp.GetRequiredService().Should().NotBeNull(); } + + private static HttpMessageHandler BuildPrimaryHandler(IServiceProvider sp, string clientName) + { + var optionsMonitor = sp.GetRequiredService>(); + var clientOptions = optionsMonitor.Get(clientName); + + var builder = A.Fake(); + var primaryHandler = (HttpMessageHandler)new HttpClientHandler(); + A.CallTo(() => builder.PrimaryHandler).Returns(primaryHandler); + A.CallToSet(() => builder.PrimaryHandler).Invokes((HttpMessageHandler h) => primaryHandler = h); + A.CallTo(() => builder.Name).Returns(clientName); + + foreach (var action in clientOptions.HttpMessageHandlerBuilderActions) + { + action(builder); + } + + return primaryHandler; + } } From e7912e9d86cde7f2a341659848d26ca2ad075462 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 26 Apr 2026 15:27:09 +0200 Subject: [PATCH 7/7] chore(deps): bump NuGet package versions in `Directory.Packages.props` - Updated `CreativeCoders.*` packages to version `6.7.3`. - Upgraded `Microsoft.Extensions.*` packages to version `10.0.7`. --- Directory.Packages.props | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index b1c1280..0d9d967 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,22 +3,22 @@ true - - - - - - - - + + + + + + + + - - - + + +