Skip to content

Commit 1983497

Browse files
authored
New command for making CCU firmware backup with CLI (#47)
* 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. * 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. * 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. * 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. * 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. * 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. * 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`.
1 parent cb08c94 commit 1983497

26 files changed

Lines changed: 1374 additions & 13 deletions

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,6 @@ Generated_Code/
231231
# to a newer Visual Studio version. Backup files are not needed,
232232
# because we have git ;-)
233233
_UpgradeReport_Files/
234-
Backup*/
235234
UpgradeLog*.XML
236235
UpgradeLog*.htm
237236
ServiceFabricBackup/

Directory.Packages.props

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,22 @@
33
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
44
</PropertyGroup>
55
<ItemGroup>
6-
<PackageVersion Include="CreativeCoders.CakeBuild" Version="6.7.2" />
7-
<PackageVersion Include="CreativeCoders.Cli.Core" Version="6.7.2" />
8-
<PackageVersion Include="CreativeCoders.Cli.Hosting" Version="6.7.2" />
9-
<PackageVersion Include="CreativeCoders.Configuration" Version="6.7.2" />
10-
<PackageVersion Include="CreativeCoders.Core" Version="6.7.2" />
11-
<PackageVersion Include="CreativeCoders.Net.JsonRpc" Version="6.7.2" />
12-
<PackageVersion Include="CreativeCoders.Net.XmlRpc" Version="6.7.2" />
13-
<PackageVersion Include="CreativeCoders.SysConsole.Cli.Actions" Version="6.7.2" />
6+
<PackageVersion Include="CreativeCoders.CakeBuild" Version="6.7.3" />
7+
<PackageVersion Include="CreativeCoders.Cli.Core" Version="6.7.3" />
8+
<PackageVersion Include="CreativeCoders.Cli.Hosting" Version="6.7.3" />
9+
<PackageVersion Include="CreativeCoders.Configuration" Version="6.7.3" />
10+
<PackageVersion Include="CreativeCoders.Core" Version="6.7.3" />
11+
<PackageVersion Include="CreativeCoders.Net.JsonRpc" Version="6.7.3" />
12+
<PackageVersion Include="CreativeCoders.Net.XmlRpc" Version="6.7.3" />
13+
<PackageVersion Include="CreativeCoders.SysConsole.Cli.Actions" Version="6.7.3" />
1414
<PackageVersion Include="coverlet.collector" Version="8.0.1" />
1515
<PackageVersion Include="Devlooped.CredentialManager" Version="2.7.0" />
1616
<PackageVersion Include="FakeItEasy" Version="9.0.1" />
1717
<PackageVersion Include="AwesomeAssertions" Version="9.4.0" />
1818
<PackageVersion Include="JetBrains.Annotations" Version="2025.2.4" />
19-
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.6" />
20-
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.6" />
21-
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.6" />
19+
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.7" />
20+
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.7" />
21+
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.7" />
2222
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
2323
<PackageVersion Include="Spectre.Console" Version="0.55.2" />
2424
<PackageVersion Include="xunit" Version="2.9.3" />

source/CreativeCoders.HomeMatic/CreativeCoders.HomeMatic.csproj

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<Project Sdk="Microsoft.NET.Sdk">
1+
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
44
<ImplicitUsings>enable</ImplicitUsings>
@@ -11,5 +11,8 @@
1111
<ProjectReference Include="..\CreativeCoders.HomeMatic.XmlRpc\CreativeCoders.HomeMatic.XmlRpc.csproj"/>
1212
</ItemGroup>
1313

14+
<ItemGroup>
15+
<PackageReference Include="Microsoft.Extensions.Http"/>
16+
</ItemGroup>
1417

1518
</Project>
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
using CreativeCoders.Core;
2+
using CreativeCoders.HomeMatic.FirmwareBackup.Internal;
3+
using System.IO.Abstractions;
4+
5+
namespace CreativeCoders.HomeMatic.FirmwareBackup;
6+
7+
/// <summary>
8+
/// Default <see cref="IFirmwareBackupClient"/> implementation. Orchestrates login, backup download
9+
/// and logout against a HomeMatic CCU.
10+
/// </summary>
11+
public sealed class FirmwareBackupClient : IFirmwareBackupClient
12+
{
13+
private readonly ICcuSessionClient _sessionClient;
14+
private readonly IFirmwareBackupDownloader _downloader;
15+
private readonly FirmwareBackupOptions _options;
16+
private readonly IFileSystem _fileSystem;
17+
18+
internal FirmwareBackupClient(
19+
ICcuSessionClient sessionClient,
20+
IFirmwareBackupDownloader downloader,
21+
FirmwareBackupOptions options,
22+
IFileSystem fileSystem)
23+
{
24+
_sessionClient = Ensure.NotNull(sessionClient);
25+
_downloader = Ensure.NotNull(downloader);
26+
_options = Ensure.NotNull(options);
27+
_fileSystem = Ensure.NotNull(fileSystem);
28+
}
29+
30+
/// <inheritdoc />
31+
public async Task<FirmwareBackupResult> CreateBackupAsync(CancellationToken cancellationToken = default)
32+
{
33+
var sessionId = await _sessionClient
34+
.LoginAsync(_options.Credential.UserName, _options.Credential.Password, cancellationToken)
35+
.ConfigureAwait(false);
36+
37+
try
38+
{
39+
var download = await _downloader.DownloadAsync(sessionId, cancellationToken).ConfigureAwait(false);
40+
41+
return new FirmwareBackupResult(
42+
download.Content,
43+
download.FileName,
44+
download.ContentLength,
45+
download.HttpResources,
46+
new LogoutDisposable(_sessionClient, sessionId));
47+
}
48+
catch
49+
{
50+
await _sessionClient.LogoutAsync(sessionId, CancellationToken.None).ConfigureAwait(false);
51+
throw;
52+
}
53+
}
54+
55+
/// <inheritdoc />
56+
public async Task<string> CreateBackupToFileAsync(string targetFilePath,
57+
CancellationToken cancellationToken = default)
58+
{
59+
Ensure.IsNotNullOrWhitespace(targetFilePath);
60+
61+
var backup = await CreateBackupAsync(cancellationToken).ConfigureAwait(false);
62+
await using var backup1 = backup.ConfigureAwait(false);
63+
64+
var resolvedPath = ResolveFilePath(targetFilePath, backup.FileName);
65+
66+
var directory = _fileSystem.Path.GetDirectoryName(resolvedPath);
67+
if (!string.IsNullOrWhiteSpace(directory))
68+
{
69+
_fileSystem.Directory.CreateDirectory(directory);
70+
}
71+
72+
var fileStream = _fileSystem.File.Create(resolvedPath);
73+
await using var stream = fileStream.ConfigureAwait(false);
74+
await backup.Content.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
75+
76+
return resolvedPath;
77+
}
78+
79+
private string ResolveFilePath(string targetFilePath, string suggestedFileName)
80+
{
81+
if (_fileSystem.Directory.Exists(targetFilePath) ||
82+
targetFilePath.EndsWith(_fileSystem.Path.DirectorySeparatorChar) ||
83+
targetFilePath.EndsWith(_fileSystem.Path.AltDirectorySeparatorChar))
84+
{
85+
return _fileSystem.Path.Combine(targetFilePath, suggestedFileName);
86+
}
87+
88+
return targetFilePath;
89+
}
90+
91+
private sealed class LogoutDisposable(ICcuSessionClient sessionClient, string sessionId) : IAsyncDisposable
92+
{
93+
public async ValueTask DisposeAsync()
94+
{
95+
await sessionClient.LogoutAsync(sessionId, CancellationToken.None).ConfigureAwait(false);
96+
}
97+
}
98+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
using CreativeCoders.Core;
2+
using CreativeCoders.HomeMatic.FirmwareBackup.Internal;
3+
using System.IO.Abstractions;
4+
5+
namespace CreativeCoders.HomeMatic.FirmwareBackup;
6+
7+
/// <summary>
8+
/// Default <see cref="IFirmwareBackupClientFactory"/> implementation. Resolves the configured
9+
/// <see cref="HttpClient"/> via <see cref="IHttpClientFactory"/> and wires up a
10+
/// <see cref="FirmwareBackupClient"/> with its internal collaborators.
11+
/// </summary>
12+
public sealed class FirmwareBackupClientFactory : IFirmwareBackupClientFactory
13+
{
14+
/// <summary>
15+
/// Name of the named <see cref="HttpClient"/> registered for firmware backup operations
16+
/// using the platform's standard server certificate validation.
17+
/// </summary>
18+
public const string HttpClientName = "CreativeCoders.HomeMatic.FirmwareBackup";
19+
20+
/// <summary>
21+
/// Name of the named <see cref="HttpClient"/> registered for firmware backup operations
22+
/// that accepts any (including self-signed) server certificate.
23+
/// </summary>
24+
public const string HttpClientNameAcceptAnyCertificate =
25+
HttpClientName + ".AcceptAnyCertificate";
26+
27+
private readonly IHttpClientFactory _httpClientFactory;
28+
private readonly IFileSystem _fileSystem;
29+
30+
/// <summary>
31+
/// Initializes a new instance of <see cref="FirmwareBackupClientFactory"/>.
32+
/// </summary>
33+
/// <param name="httpClientFactory">Factory used to obtain the named HTTP client.</param>
34+
/// <param name="fileSystem">File system abstraction used by created clients.</param>
35+
public FirmwareBackupClientFactory(IHttpClientFactory httpClientFactory, IFileSystem fileSystem)
36+
{
37+
_httpClientFactory = Ensure.NotNull(httpClientFactory);
38+
_fileSystem = Ensure.NotNull(fileSystem);
39+
}
40+
41+
/// <inheritdoc />
42+
public IFirmwareBackupClient Create(FirmwareBackupOptions options)
43+
{
44+
Ensure.NotNull(options);
45+
46+
var clientName = options.AcceptAnyServerCertificate
47+
? HttpClientNameAcceptAnyCertificate
48+
: HttpClientName;
49+
50+
var httpClient = _httpClientFactory.CreateClient(clientName);
51+
httpClient.Timeout = options.Timeout;
52+
53+
var sessionClient = new CcuSessionClient(httpClient, options.BaseUrl, options.JsonRpcPath);
54+
var downloader = new FirmwareBackupDownloader(
55+
httpClient,
56+
options.BaseUrl,
57+
options.BackupCgiPath,
58+
options.BackupAction);
59+
60+
return new FirmwareBackupClient(sessionClient, downloader, options, _fileSystem);
61+
}
62+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
using System.Net;
2+
using JetBrains.Annotations;
3+
4+
namespace CreativeCoders.HomeMatic.FirmwareBackup;
5+
6+
/// <summary>
7+
/// Exception thrown when a firmware backup operation against a HomeMatic CCU fails.
8+
/// </summary>
9+
[PublicAPI]
10+
public class FirmwareBackupException : Exception
11+
{
12+
/// <summary>
13+
/// Initializes a new instance of <see cref="FirmwareBackupException"/>.
14+
/// </summary>
15+
/// <param name="message">A human-readable description of the failure.</param>
16+
public FirmwareBackupException(string message) : base(message)
17+
{
18+
}
19+
20+
/// <summary>
21+
/// Initializes a new instance of <see cref="FirmwareBackupException"/> with an inner exception.
22+
/// </summary>
23+
/// <param name="message">A human-readable description of the failure.</param>
24+
/// <param name="innerException">The exception that caused the failure.</param>
25+
public FirmwareBackupException(string message, Exception innerException) : base(message, innerException)
26+
{
27+
}
28+
29+
/// <summary>
30+
/// Initializes a new instance of <see cref="FirmwareBackupException"/> describing an HTTP failure.
31+
/// </summary>
32+
/// <param name="message">A human-readable description of the failure.</param>
33+
/// <param name="statusCode">HTTP status code returned by the CCU.</param>
34+
/// <param name="responseBody">Optional truncated response body for diagnostics.</param>
35+
public FirmwareBackupException(string message, HttpStatusCode statusCode, string? responseBody = null)
36+
: base(message)
37+
{
38+
StatusCode = statusCode;
39+
ResponseBody = responseBody;
40+
}
41+
42+
/// <summary>
43+
/// Gets the HTTP status code returned by the CCU, if available.
44+
/// </summary>
45+
public HttpStatusCode? StatusCode { get; }
46+
47+
/// <summary>
48+
/// Gets a (possibly truncated) snippet of the CCU response body for diagnostics.
49+
/// </summary>
50+
public string? ResponseBody { get; }
51+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
using System.Net;
2+
using CreativeCoders.Core;
3+
using JetBrains.Annotations;
4+
5+
namespace CreativeCoders.HomeMatic.FirmwareBackup;
6+
7+
/// <summary>
8+
/// Connection and behavior options used to create a firmware backup of a HomeMatic CCU.
9+
/// </summary>
10+
[PublicAPI]
11+
public class FirmwareBackupOptions
12+
{
13+
/// <summary>
14+
/// Initializes a new instance of <see cref="FirmwareBackupOptions"/>.
15+
/// </summary>
16+
/// <param name="baseUrl">Base URL of the CCU (e.g. <c>https://homematic-ccu.local</c>).</param>
17+
/// <param name="credential">Credentials of a CCU user that is allowed to download backups.</param>
18+
public FirmwareBackupOptions(Uri baseUrl, NetworkCredential credential)
19+
{
20+
BaseUrl = Ensure.NotNull(baseUrl);
21+
Credential = Ensure.NotNull(credential);
22+
}
23+
24+
/// <summary>
25+
/// Gets the base URL of the CCU.
26+
/// </summary>
27+
public Uri BaseUrl { get; }
28+
29+
/// <summary>
30+
/// Gets the credentials used to log in against the CCU.
31+
/// </summary>
32+
public NetworkCredential Credential { get; }
33+
34+
/// <summary>
35+
/// Gets or sets the relative path of the JSON-RPC endpoint used for login/logout. Default: <c>/api/homematic.cgi</c>.
36+
/// </summary>
37+
public string JsonRpcPath { get; set; } = "/api/homematic.cgi";
38+
39+
/// <summary>
40+
/// Gets or sets the relative path of the CGI endpoint that produces the firmware backup file.
41+
/// Default: <c>/config/cp_security.cgi</c>.
42+
/// </summary>
43+
public string BackupCgiPath { get; set; } = "/config/cp_security.cgi";
44+
45+
/// <summary>
46+
/// Gets or sets the form action value sent to the backup CGI endpoint. Default: <c>create_backup</c>.
47+
/// </summary>
48+
public string BackupAction { get; set; } = "create_backup";
49+
50+
/// <summary>
51+
/// Gets or sets a value indicating whether the HTTP client should accept any (including self-signed)
52+
/// server certificate. CCU devices typically use a self-signed certificate, therefore the default is
53+
/// <see langword="true"/>.
54+
/// </summary>
55+
public bool AcceptAnyServerCertificate { get; set; } = true;
56+
57+
/// <summary>
58+
/// Gets or sets the request timeout used for both the JSON-RPC and the CGI download call.
59+
/// Default: 5 minutes (creating a backup on the CCU can take a while).
60+
/// </summary>
61+
public TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(5);
62+
}

0 commit comments

Comments
 (0)