Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
22 changes: 11 additions & 11 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,22 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="CreativeCoders.CakeBuild" Version="6.7.2" />
<PackageVersion Include="CreativeCoders.Cli.Core" Version="6.7.2" />
<PackageVersion Include="CreativeCoders.Cli.Hosting" Version="6.7.2" />
<PackageVersion Include="CreativeCoders.Configuration" Version="6.7.2" />
<PackageVersion Include="CreativeCoders.Core" Version="6.7.2" />
<PackageVersion Include="CreativeCoders.Net.JsonRpc" Version="6.7.2" />
<PackageVersion Include="CreativeCoders.Net.XmlRpc" Version="6.7.2" />
<PackageVersion Include="CreativeCoders.SysConsole.Cli.Actions" Version="6.7.2" />
<PackageVersion Include="CreativeCoders.CakeBuild" Version="6.7.3" />
<PackageVersion Include="CreativeCoders.Cli.Core" Version="6.7.3" />
<PackageVersion Include="CreativeCoders.Cli.Hosting" Version="6.7.3" />
<PackageVersion Include="CreativeCoders.Configuration" Version="6.7.3" />
<PackageVersion Include="CreativeCoders.Core" Version="6.7.3" />
<PackageVersion Include="CreativeCoders.Net.JsonRpc" Version="6.7.3" />
<PackageVersion Include="CreativeCoders.Net.XmlRpc" Version="6.7.3" />
<PackageVersion Include="CreativeCoders.SysConsole.Cli.Actions" Version="6.7.3" />
<PackageVersion Include="coverlet.collector" Version="8.0.1" />
<PackageVersion Include="Devlooped.CredentialManager" Version="2.7.0" />
<PackageVersion Include="FakeItEasy" Version="9.0.1" />
<PackageVersion Include="AwesomeAssertions" Version="9.4.0" />
<PackageVersion Include="JetBrains.Annotations" Version="2025.2.4" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.7" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
<PackageVersion Include="Spectre.Console" Version="0.55.2" />
<PackageVersion Include="xunit" Version="2.9.3" />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings>
Expand All @@ -11,5 +11,8 @@
<ProjectReference Include="..\CreativeCoders.HomeMatic.XmlRpc\CreativeCoders.HomeMatic.XmlRpc.csproj"/>
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http"/>
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
using CreativeCoders.Core;
using CreativeCoders.HomeMatic.FirmwareBackup.Internal;
using System.IO.Abstractions;

namespace CreativeCoders.HomeMatic.FirmwareBackup;

/// <summary>
/// Default <see cref="IFirmwareBackupClient"/> implementation. Orchestrates login, backup download
/// and logout against a HomeMatic CCU.
/// </summary>
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);
}

/// <inheritdoc />
public async Task<FirmwareBackupResult> 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;
}
}

/// <inheritdoc />
public async Task<string> 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);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using CreativeCoders.Core;
using CreativeCoders.HomeMatic.FirmwareBackup.Internal;
using System.IO.Abstractions;

namespace CreativeCoders.HomeMatic.FirmwareBackup;

/// <summary>
/// Default <see cref="IFirmwareBackupClientFactory"/> implementation. Resolves the configured
/// <see cref="HttpClient"/> via <see cref="IHttpClientFactory"/> and wires up a
/// <see cref="FirmwareBackupClient"/> with its internal collaborators.
/// </summary>
public sealed class FirmwareBackupClientFactory : IFirmwareBackupClientFactory
{
/// <summary>
/// Name of the named <see cref="HttpClient"/> registered for firmware backup operations
/// using the platform's standard server certificate validation.
/// </summary>
public const string HttpClientName = "CreativeCoders.HomeMatic.FirmwareBackup";

/// <summary>
/// Name of the named <see cref="HttpClient"/> registered for firmware backup operations
/// that accepts any (including self-signed) server certificate.
/// </summary>
public const string HttpClientNameAcceptAnyCertificate =
HttpClientName + ".AcceptAnyCertificate";

private readonly IHttpClientFactory _httpClientFactory;
private readonly IFileSystem _fileSystem;

/// <summary>
/// Initializes a new instance of <see cref="FirmwareBackupClientFactory"/>.
/// </summary>
/// <param name="httpClientFactory">Factory used to obtain the named HTTP client.</param>
/// <param name="fileSystem">File system abstraction used by created clients.</param>
public FirmwareBackupClientFactory(IHttpClientFactory httpClientFactory, IFileSystem fileSystem)
{
_httpClientFactory = Ensure.NotNull(httpClientFactory);
_fileSystem = Ensure.NotNull(fileSystem);
}

/// <inheritdoc />
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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using System.Net;
using JetBrains.Annotations;

namespace CreativeCoders.HomeMatic.FirmwareBackup;

/// <summary>
/// Exception thrown when a firmware backup operation against a HomeMatic CCU fails.
/// </summary>
[PublicAPI]
public class FirmwareBackupException : Exception
{
/// <summary>
/// Initializes a new instance of <see cref="FirmwareBackupException"/>.
/// </summary>
/// <param name="message">A human-readable description of the failure.</param>
public FirmwareBackupException(string message) : base(message)
{
}

/// <summary>
/// Initializes a new instance of <see cref="FirmwareBackupException"/> with an inner exception.
/// </summary>
/// <param name="message">A human-readable description of the failure.</param>
/// <param name="innerException">The exception that caused the failure.</param>
public FirmwareBackupException(string message, Exception innerException) : base(message, innerException)
{
}

/// <summary>
/// Initializes a new instance of <see cref="FirmwareBackupException"/> describing an HTTP failure.
/// </summary>
/// <param name="message">A human-readable description of the failure.</param>
/// <param name="statusCode">HTTP status code returned by the CCU.</param>
/// <param name="responseBody">Optional truncated response body for diagnostics.</param>
public FirmwareBackupException(string message, HttpStatusCode statusCode, string? responseBody = null)
: base(message)
{
StatusCode = statusCode;
ResponseBody = responseBody;
}

/// <summary>
/// Gets the HTTP status code returned by the CCU, if available.
/// </summary>
public HttpStatusCode? StatusCode { get; }

/// <summary>
/// Gets a (possibly truncated) snippet of the CCU response body for diagnostics.
/// </summary>
public string? ResponseBody { get; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using System.Net;
using CreativeCoders.Core;
using JetBrains.Annotations;

namespace CreativeCoders.HomeMatic.FirmwareBackup;

/// <summary>
/// Connection and behavior options used to create a firmware backup of a HomeMatic CCU.
/// </summary>
[PublicAPI]
public class FirmwareBackupOptions
{
/// <summary>
/// Initializes a new instance of <see cref="FirmwareBackupOptions"/>.
/// </summary>
/// <param name="baseUrl">Base URL of the CCU (e.g. <c>https://homematic-ccu.local</c>).</param>
/// <param name="credential">Credentials of a CCU user that is allowed to download backups.</param>
public FirmwareBackupOptions(Uri baseUrl, NetworkCredential credential)
{
BaseUrl = Ensure.NotNull(baseUrl);
Credential = Ensure.NotNull(credential);
}

/// <summary>
/// Gets the base URL of the CCU.
/// </summary>
public Uri BaseUrl { get; }

/// <summary>
/// Gets the credentials used to log in against the CCU.
/// </summary>
public NetworkCredential Credential { get; }

/// <summary>
/// Gets or sets the relative path of the JSON-RPC endpoint used for login/logout. Default: <c>/api/homematic.cgi</c>.
/// </summary>
public string JsonRpcPath { get; set; } = "/api/homematic.cgi";

/// <summary>
/// Gets or sets the relative path of the CGI endpoint that produces the firmware backup file.
/// Default: <c>/config/cp_security.cgi</c>.
/// </summary>
public string BackupCgiPath { get; set; } = "/config/cp_security.cgi";

/// <summary>
/// Gets or sets the form action value sent to the backup CGI endpoint. Default: <c>create_backup</c>.
/// </summary>
public string BackupAction { get; set; } = "create_backup";

/// <summary>
/// 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
/// <see langword="true"/>.
/// </summary>
public bool AcceptAnyServerCertificate { get; set; } = true;

/// <summary>
/// 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).
/// </summary>
public TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(5);
}
Loading
Loading