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/Directory.Packages.props b/Directory.Packages.props
index b1c1280..0d9d967 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -3,22 +3,22 @@
true
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
+
+
+
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..1d18648
--- /dev/null
+++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupClient.cs
@@ -0,0 +1,98 @@
+using CreativeCoders.Core;
+using CreativeCoders.HomeMatic.FirmwareBackup.Internal;
+using System.IO.Abstractions;
+
+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;
+ private readonly IFileSystem _fileSystem;
+
+ internal FirmwareBackupClient(
+ ICcuSessionClient sessionClient,
+ IFirmwareBackupDownloader downloader,
+ FirmwareBackupOptions options,
+ IFileSystem fileSystem)
+ {
+ _sessionClient = Ensure.NotNull(sessionClient);
+ _downloader = Ensure.NotNull(downloader);
+ _options = Ensure.NotNull(options);
+ _fileSystem = Ensure.NotNull(fileSystem);
+ }
+
+ ///
+ 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);
+
+ var backup = await CreateBackupAsync(cancellationToken).ConfigureAwait(false);
+ await using var backup1 = backup.ConfigureAwait(false);
+
+ var resolvedPath = ResolveFilePath(targetFilePath, backup.FileName);
+
+ var directory = _fileSystem.Path.GetDirectoryName(resolvedPath);
+ if (!string.IsNullOrWhiteSpace(directory))
+ {
+ _fileSystem.Directory.CreateDirectory(directory);
+ }
+
+ var fileStream = _fileSystem.File.Create(resolvedPath);
+ await using var stream = fileStream.ConfigureAwait(false);
+ await backup.Content.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
+
+ return resolvedPath;
+ }
+
+ private string ResolveFilePath(string targetFilePath, string suggestedFileName)
+ {
+ if (_fileSystem.Directory.Exists(targetFilePath) ||
+ targetFilePath.EndsWith(_fileSystem.Path.DirectorySeparatorChar) ||
+ targetFilePath.EndsWith(_fileSystem.Path.AltDirectorySeparatorChar))
+ {
+ return _fileSystem.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..d76c9c3
--- /dev/null
+++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupClientFactory.cs
@@ -0,0 +1,62 @@
+using CreativeCoders.Core;
+using CreativeCoders.HomeMatic.FirmwareBackup.Internal;
+using System.IO.Abstractions;
+
+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
+ /// 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;
+
+ ///
+ /// Initializes a new instance of .
+ ///
+ /// Factory used to obtain the named HTTP client.
+ /// File system abstraction used by created clients.
+ public FirmwareBackupClientFactory(IHttpClientFactory httpClientFactory, IFileSystem fileSystem)
+ {
+ _httpClientFactory = Ensure.NotNull(httpClientFactory);
+ _fileSystem = Ensure.NotNull(fileSystem);
+ }
+
+ ///
+ public IFirmwareBackupClient Create(FirmwareBackupOptions options)
+ {
+ Ensure.NotNull(options);
+
+ var clientName = options.AcceptAnyServerCertificate
+ ? HttpClientNameAcceptAnyCertificate
+ : HttpClientName;
+
+ var httpClient = _httpClientFactory.CreateClient(clientName);
+ 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, _fileSystem);
+ }
+}
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..67e016a
--- /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 = Ensure.NotNull(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..d6fab56
--- /dev/null
+++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupServiceCollectionExtensions.cs
@@ -0,0 +1,41 @@
+using System.Diagnostics.CodeAnalysis;
+using CreativeCoders.Core.IO;
+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.
+ [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.HttpClientNameAcceptAnyCertificate)
+ .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..496dc26
--- /dev/null
+++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/Internal/CcuSessionClient.cs
@@ -0,0 +1,125 @@
+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);
+ request.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..a0e5cf9
--- /dev/null
+++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/Internal/FirmwareBackupDownloader.cs
@@ -0,0 +1,108 @@
+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;
+
+ return string.IsNullOrWhiteSpace(fileName)
+ ? DefaultFileName
+ : 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/Backup/BackupCcuCommand.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/BackupCcuCommand.cs
new file mode 100644
index 0000000..873d445
--- /dev/null
+++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/BackupCcuCommand.cs
@@ -0,0 +1,85 @@
+using CreativeCoders.Cli.Core;
+using CreativeCoders.Core;
+using CreativeCoders.HomeMatic.FirmwareBackup;
+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;
+
+[UsedImplicitly]
+[CliCommand([CcuCommandGroup.Name, "backup"], Description = "Create a firmware backup of a configured CCU")]
+public class BackupCcuCommand(
+ IAnsiConsole console,
+ ICcuConnectionsStore ccuConnectionsStore,
+ IFirmwareBackupClientFactory firmwareBackupClientFactory,
+ IFileSystem fileSystem)
+ : ICliCommand
+{
+ private readonly IAnsiConsole _console = Ensure.NotNull(console);
+
+ private readonly ICcuConnectionsStore _ccuConnectionsStore = Ensure.NotNull(ccuConnectionsStore);
+
+ 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))
+ {
+ _console.MarkupLine("[bold italic red3]A CCU connection name is required.[/]");
+ 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
+ .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 targetPath = _fileSystem.Path.GetFullPath(options.OutputFile);
+ var targetDirectory = _fileSystem.Path.GetDirectoryName(targetPath);
+
+ if (!string.IsNullOrEmpty(targetDirectory))
+ {
+ _fileSystem.Directory.CreateDirectory(targetDirectory);
+ }
+
+ _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;
+ }
+ }
+}
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..715a7ac
--- /dev/null
+++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/BackupCcuOptions.cs
@@ -0,0 +1,15 @@
+using CreativeCoders.SysConsole.Cli.Parsing;
+using JetBrains.Annotations;
+
+namespace CreativeCoders.HomeMatic.Tools.Cli.Commands.Ccu.Backup;
+
+[PublicAPI]
+public class BackupCcuOptions
+{
+ [OptionValue(0, IsRequired = true, HelpText = "Name of the configured CCU connection")]
+ public string Name { 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.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/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();
}
}
diff --git a/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupClientFactoryIntegrationTests.cs b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupClientFactoryIntegrationTests.cs
new file mode 100644
index 0000000..240e2a0
--- /dev/null
+++ b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupClientFactoryIntegrationTests.cs
@@ -0,0 +1,193 @@
+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;
+
+public class FirmwareBackupClientFactoryIntegrationTests
+{
+ private const string FakeSessionId = "session-id-xyz";
+ private static readonly byte[] BackupPayload = "BACKUP-CONTENT"u8.ToArray();
+
+ [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 fileSystem = new ServiceCollection()
+ .AddFileSystem()
+ .BuildServiceProvider()
+ .GetRequiredService();
+ var factory = new FirmwareBackupClientFactory(httpClientFactory, fileSystem);
+ return (factory, handler);
+ }
+}
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/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..05aec79
--- /dev/null
+++ b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupServiceCollectionExtensionsTests.cs
@@ -0,0 +1,132 @@
+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;
+
+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 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()
+ {
+ // Arrange
+ IServiceCollection services = new ServiceCollection();
+
+ // Act
+ services.AddHomeMatic();
+ var sp = services.BuildServiceProvider();
+
+ // 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;
+ }
+}
diff --git a/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/QueueingHttpMessageHandler.cs b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/QueueingHttpMessageHandler.cs
new file mode 100644
index 0000000..e28f8b3
--- /dev/null
+++ b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/QueueingHttpMessageHandler.cs
@@ -0,0 +1,61 @@
+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));
+
+ return _responders.Count == 0
+ ? throw new InvalidOperationException("No more responses queued.")
+ : _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..6ac4850
--- /dev/null
+++ b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/SingleHandlerHttpClientFactory.cs
@@ -0,0 +1,13 @@
+namespace CreativeCoders.HomeMatic.Tests.FirmwareBackup;
+
+///
+/// Minimal that returns a single backed by
+/// the given .
+///
+internal sealed class SingleHandlerHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory
+{
+ public HttpClient CreateClient(string name)
+ {
+ return new HttpClient(handler, disposeHandler: false);
+ }
+}