From f1b4f97d9ba1c4c6c0952cc5f36d2e5b95590cdf Mon Sep 17 00:00:00 2001
From: darthsharp <48331467+darthsharp@users.noreply.github.com>
Date: Sat, 11 Jul 2026 15:50:37 +0200
Subject: [PATCH 1/3] Refactor exceptions to use `DeviceNotFoundException` and
enhance param set export and filtering logic.
- Replaced `KeyNotFoundException` with `DeviceNotFoundException` across APIs and tests.
- Introduced error handling for param set exports with `ReadError`.
- Enhanced whitelist filtering for param sets and updated CLI with validation logic.
- Added comprehensive tests for param set errors, whitelist filtering, and updated export workflows.
---
.../CompleteCcuDeviceBuildOptions.cs | 18 ++
.../DeviceNotFoundException.cs | 21 ++
.../Devices/ParamSetValueWithDescription.cs | 5 +-
...s.cs => ParamSetValuesWithDescriptions.cs} | 6 +
.../ICcuClient.cs | 2 +
.../IMultiCcuClient.cs | 2 +
.../WhitelistFilter.cs | 33 ++
.../Exceptions/HomeMaticException.cs | 8 +
source/CreativeCoders.HomeMatic/CcuClient.cs | 2 +-
source/CreativeCoders.HomeMatic/CcuDevice.cs | 5 +-
.../CompleteCcuDeviceBuilder.cs | 86 +++++-
.../CreativeCoders.HomeMatic.csproj | 13 +-
.../Exporting/DeviceExportOptions.cs | 20 +-
.../Exporting/DeviceExporter.cs | 5 +-
.../Exporting/ParamSetExportData.cs | 6 +
.../MultiCcuClient.cs | 6 +-
.../ShowDetails/ShowDeviceDetailsCommand.cs | 22 +-
.../ShowDetails/ShowDeviceDetailsOptions.cs | 4 +
...ativeCoders.HomeMatic.Tools.Cli.Hmc.csproj | 9 +-
.../Program.cs | 44 ++-
.../CcuClientTests.cs | 8 +-
.../CcuDeviceTests.cs | 8 +-
.../CompleteCcuDeviceBuildOptionsTests.cs | 72 +++++
.../CompleteCcuDeviceBuilderTests.cs | 289 +++++++++++++++++-
.../CreativeCoders.HomeMatic.Tests.csproj | 9 +-
.../CompleteCcuDeviceChannelFakeBuilder.cs | 6 +-
.../Exporting/CompleteCcuDeviceFakeBuilder.cs | 6 +-
.../Exporting/DeviceExporterTests.cs | 110 +++++++
.../Exporting/ParamSetValuesBuilder.cs | 11 +
.../MultiCcuClientTests.cs | 68 ++++-
.../ShowDeviceDetailsCommandTests.cs | 288 +++++++++++++++++
31 files changed, 1086 insertions(+), 106 deletions(-)
create mode 100644 source/CreativeCoders.HomeMatic.Core/DeviceNotFoundException.cs
rename source/CreativeCoders.HomeMatic.Core/Devices/{IParamSetValuesWithDescriptions.cs => ParamSetValuesWithDescriptions.cs} (71%)
create mode 100644 source/CreativeCoders.HomeMatic.Core/WhitelistFilter.cs
create mode 100644 tests/CreativeCoders.HomeMatic.Tests/CompleteCcuDeviceBuildOptionsTests.cs
create mode 100644 tests/CreativeCoders.HomeMatic.Tools.Cli.Commands.Tests/Device/ShowDetails/ShowDeviceDetailsCommandTests.cs
diff --git a/source/CreativeCoders.HomeMatic.Core/CompleteCcuDeviceBuildOptions.cs b/source/CreativeCoders.HomeMatic.Core/CompleteCcuDeviceBuildOptions.cs
index 055c5f0..fb5c75b 100644
--- a/source/CreativeCoders.HomeMatic.Core/CompleteCcuDeviceBuildOptions.cs
+++ b/source/CreativeCoders.HomeMatic.Core/CompleteCcuDeviceBuildOptions.cs
@@ -1,3 +1,4 @@
+using System.Collections.Generic;
using CreativeCoders.HomeMatic.XmlRpc.Links;
using JetBrains.Annotations;
@@ -21,4 +22,21 @@ public class CompleteCcuDeviceBuildOptions
///
/// The value. Default is .
public GetLinksFlags LinksFlags { get; set; } = GetLinksFlags.None;
+
+ ///
+ /// Whitelist of ParamSet keys to fetch from the CCU (e.g. "MASTER", "VALUES").
+ /// If empty or null, all ParamSets are fetched.
+ ///
+ /// The collection of allowed ParamSet keys, or to fetch all.
+ public ICollection? ParamSetWhitelist { get; set; }
+
+ ///
+ /// Determines whether a ParamSet key is allowed based on the .
+ ///
+ /// The ParamSet key to check.
+ /// true if the key is allowed or no whitelist is configured; otherwise false.
+ public bool IsParamSetAllowed(string paramSetKey)
+ {
+ return WhitelistFilter.IsAllowed(ParamSetWhitelist, paramSetKey);
+ }
}
diff --git a/source/CreativeCoders.HomeMatic.Core/DeviceNotFoundException.cs b/source/CreativeCoders.HomeMatic.Core/DeviceNotFoundException.cs
new file mode 100644
index 0000000..91e52eb
--- /dev/null
+++ b/source/CreativeCoders.HomeMatic.Core/DeviceNotFoundException.cs
@@ -0,0 +1,21 @@
+using CreativeCoders.Core;
+using CreativeCoders.HomeMatic.XmlRpc.Exceptions;
+using JetBrains.Annotations;
+
+namespace CreativeCoders.HomeMatic.Core;
+
+///
+/// Exception that is thrown when a device or channel address cannot be resolved.
+///
+/// The device or channel address that could not be found.
+/// An optional error message. If omitted, a default message containing the address is used.
+[PublicAPI]
+public class DeviceNotFoundException(string address, string? message = null)
+ : HomeMaticException(message ?? $"Device with address '{address}' not found.")
+{
+ ///
+ /// Gets the device or channel address that could not be found.
+ ///
+ /// The device or channel address.
+ public string Address { get; } = Ensure.NotNull(address);
+}
diff --git a/source/CreativeCoders.HomeMatic.Core/Devices/ParamSetValueWithDescription.cs b/source/CreativeCoders.HomeMatic.Core/Devices/ParamSetValueWithDescription.cs
index b744cdc..ecb438e 100644
--- a/source/CreativeCoders.HomeMatic.Core/Devices/ParamSetValueWithDescription.cs
+++ b/source/CreativeCoders.HomeMatic.Core/Devices/ParamSetValueWithDescription.cs
@@ -14,6 +14,7 @@ public class ParamSetValueWithDescription
///
/// Gets the description that belongs to the parameter.
///
- /// The instance.
- public required CcuParameterDescription Description { get; init; }
+ /// The instance, or if the CCU
+ /// did not provide a description for the parameter.
+ public required CcuParameterDescription? Description { get; init; }
}
diff --git a/source/CreativeCoders.HomeMatic.Core/Devices/IParamSetValuesWithDescriptions.cs b/source/CreativeCoders.HomeMatic.Core/Devices/ParamSetValuesWithDescriptions.cs
similarity index 71%
rename from source/CreativeCoders.HomeMatic.Core/Devices/IParamSetValuesWithDescriptions.cs
rename to source/CreativeCoders.HomeMatic.Core/Devices/ParamSetValuesWithDescriptions.cs
index 0e2c190..61c909e 100644
--- a/source/CreativeCoders.HomeMatic.Core/Devices/IParamSetValuesWithDescriptions.cs
+++ b/source/CreativeCoders.HomeMatic.Core/Devices/ParamSetValuesWithDescriptions.cs
@@ -18,4 +18,10 @@ public class ParamSetValuesWithDescriptions
///
/// The enumerable of entries.
public required IEnumerable ParamSetValues { get; init; }
+
+ ///
+ /// Gets the error message if reading the parameter-set values from the CCU failed.
+ ///
+ /// The error message, or if the values were read successfully.
+ public string? ReadError { get; init; }
}
diff --git a/source/CreativeCoders.HomeMatic.Core/ICcuClient.cs b/source/CreativeCoders.HomeMatic.Core/ICcuClient.cs
index 5ac5dbf..b3ecb9f 100644
--- a/source/CreativeCoders.HomeMatic.Core/ICcuClient.cs
+++ b/source/CreativeCoders.HomeMatic.Core/ICcuClient.cs
@@ -22,6 +22,7 @@ public interface ICcuClient
///
/// The device address.
/// A task that yields the matching .
+ /// Thrown when the CCU does not know a device with the given address.
Task GetDeviceAsync(string address);
///
@@ -38,6 +39,7 @@ Task> GetCompleteDevicesAsync(
/// The device address.
/// Optional build options controlling whether links are fetched.
/// A task that yields the matching .
+ /// Thrown when the CCU does not know a device with the given address.
Task GetCompleteDeviceAsync(string address,
CompleteCcuDeviceBuildOptions? buildOptions = null);
diff --git a/source/CreativeCoders.HomeMatic.Core/IMultiCcuClient.cs b/source/CreativeCoders.HomeMatic.Core/IMultiCcuClient.cs
index 8832986..8995991 100644
--- a/source/CreativeCoders.HomeMatic.Core/IMultiCcuClient.cs
+++ b/source/CreativeCoders.HomeMatic.Core/IMultiCcuClient.cs
@@ -22,6 +22,7 @@ public interface IMultiCcuClient
///
/// The device address.
/// A task that yields the matching .
+ /// Thrown when no configured CCU knows a device with the given address.
Task GetDeviceAsync(string address);
///
@@ -38,6 +39,7 @@ Task> GetCompleteDevicesAsync(
/// The device address.
/// Optional build options controlling whether links are fetched.
/// A task that yields the matching .
+ /// Thrown when no configured CCU knows a device with the given address.
Task GetCompleteDeviceAsync(string address,
CompleteCcuDeviceBuildOptions? buildOptions = null);
}
diff --git a/source/CreativeCoders.HomeMatic.Core/WhitelistFilter.cs b/source/CreativeCoders.HomeMatic.Core/WhitelistFilter.cs
new file mode 100644
index 0000000..6121b9a
--- /dev/null
+++ b/source/CreativeCoders.HomeMatic.Core/WhitelistFilter.cs
@@ -0,0 +1,33 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using CreativeCoders.Core;
+using JetBrains.Annotations;
+
+namespace CreativeCoders.HomeMatic.Core;
+
+///
+/// Provides the shared whitelist semantics used by ParamSet and parameter-value filters.
+///
+[PublicAPI]
+public static class WhitelistFilter
+{
+ ///
+ /// Determines whether a key is allowed based on the given whitelist.
+ ///
+ /// The whitelist to check against, or for no filtering.
+ /// The key to check. The comparison is case-insensitive.
+ /// if the key is contained in the whitelist or the whitelist is
+ /// or empty; otherwise, .
+ public static bool IsAllowed(ICollection? whitelist, string key)
+ {
+ Ensure.NotNull(key);
+
+ if (whitelist is null || whitelist.Count == 0)
+ {
+ return true;
+ }
+
+ return whitelist.Contains(key, StringComparer.OrdinalIgnoreCase);
+ }
+}
diff --git a/source/CreativeCoders.HomeMatic.XmlRpc/Exceptions/HomeMaticException.cs b/source/CreativeCoders.HomeMatic.XmlRpc/Exceptions/HomeMaticException.cs
index ce07018..f05277e 100644
--- a/source/CreativeCoders.HomeMatic.XmlRpc/Exceptions/HomeMaticException.cs
+++ b/source/CreativeCoders.HomeMatic.XmlRpc/Exceptions/HomeMaticException.cs
@@ -7,6 +7,14 @@ namespace CreativeCoders.HomeMatic.XmlRpc.Exceptions;
///
public abstract class HomeMaticException : Exception
{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The error message that explains the reason for the exception.
+ protected HomeMaticException(string message) : base(message)
+ {
+ }
+
///
/// Initializes a new instance of the class.
///
diff --git a/source/CreativeCoders.HomeMatic/CcuClient.cs b/source/CreativeCoders.HomeMatic/CcuClient.cs
index 62750c5..d5c1dfa 100644
--- a/source/CreativeCoders.HomeMatic/CcuClient.cs
+++ b/source/CreativeCoders.HomeMatic/CcuClient.cs
@@ -69,7 +69,7 @@ public async Task GetDeviceAsync(string address)
{
return (await GetDevicesAsync().ConfigureAwait(false))
.FirstOrDefault(device => device.Uri.Address.Equals(address, StringComparison.OrdinalIgnoreCase))
- ?? throw new KeyNotFoundException($"Device with address '{address}' not found.");
+ ?? throw new DeviceNotFoundException(address);
}
///
diff --git a/source/CreativeCoders.HomeMatic/CcuDevice.cs b/source/CreativeCoders.HomeMatic/CcuDevice.cs
index b38bb57..a2e8d55 100644
--- a/source/CreativeCoders.HomeMatic/CcuDevice.cs
+++ b/source/CreativeCoders.HomeMatic/CcuDevice.cs
@@ -1,3 +1,4 @@
+using CreativeCoders.HomeMatic.Core;
using CreativeCoders.HomeMatic.Core.Devices;
using CreativeCoders.HomeMatic.XmlRpc.Client;
using CreativeCoders.HomeMatic.XmlRpc.Devices;
@@ -40,13 +41,13 @@ public class CcuDevice(IHomeMaticXmlRpcApi api) : CcuDeviceBase(api), ICcuDevice
///
/// The full address of the channel (for example "ABC0001234:1").
/// A task that yields the matching .
- /// Thrown when no channel with the specified address exists on this device.
+ /// Thrown when no channel with the specified address exists on this device.
public Task GetChannelAsync(string channelAddress)
{
var channel = Channels.FirstOrDefault(x => x.Uri.Address == channelAddress);
return channel != null
? Task.FromResult(channel)
- : throw new KeyNotFoundException(
+ : throw new DeviceNotFoundException(channelAddress,
$"Channel with address '{channelAddress}' not found.");
}
}
diff --git a/source/CreativeCoders.HomeMatic/CompleteCcuDeviceBuilder.cs b/source/CreativeCoders.HomeMatic/CompleteCcuDeviceBuilder.cs
index 5a61233..1af96b1 100644
--- a/source/CreativeCoders.HomeMatic/CompleteCcuDeviceBuilder.cs
+++ b/source/CreativeCoders.HomeMatic/CompleteCcuDeviceBuilder.cs
@@ -1,6 +1,8 @@
using CreativeCoders.HomeMatic.Core;
using CreativeCoders.HomeMatic.Core.Devices;
using CreativeCoders.HomeMatic.Core.Parameters;
+using CreativeCoders.HomeMatic.XmlRpc.Exceptions;
+using CreativeCoders.Net.XmlRpc.Exceptions;
namespace CreativeCoders.HomeMatic;
@@ -11,6 +13,8 @@ namespace CreativeCoders.HomeMatic;
///
public class CompleteCcuDeviceBuilder : ICompleteCcuDeviceBuilder
{
+ private const int FaultCodeDeviceNotReachable = -321;
+
///
public async Task BuildAsync(ICcuDevice device, CompleteCcuDeviceBuildOptions? options = null)
{
@@ -20,7 +24,7 @@ public async Task BuildAsync(ICcuDevice device, CompleteCcuD
{
DeviceData = device,
Channels = channels,
- ParamSetValues = await GetParamSetValuesAsync(device).ConfigureAwait(false)
+ ParamSetValues = await GetParamSetValuesAsync(device, options).ConfigureAwait(false)
};
return completeDevice;
@@ -40,7 +44,7 @@ private static async Task> GetChannelsAsy
var completeChannel = new CompleteCcuDeviceChannel
{
ChannelData = ccuDeviceChannel,
- ParamSetValues = await GetParamSetValuesAsync(ccuDeviceChannel).ConfigureAwait(false),
+ ParamSetValues = await GetParamSetValuesAsync(ccuDeviceChannel, options).ConfigureAwait(false),
Links = links
};
@@ -50,29 +54,81 @@ private static async Task> GetChannelsAsy
return [..channels];
}
- private static async Task> GetParamSetValuesAsync(ICcuDeviceBase device)
+ private static async Task> GetParamSetValuesAsync(
+ ICcuDeviceBase device, CompleteCcuDeviceBuildOptions? options)
{
var paramSetValues = new List();
- foreach (var paramSetKey in device.ParamSets.Where(x => x != ParamSetKey.Link))
+ var paramSetKeys = device.ParamSets
+ .Where(x => x != ParamSetKey.Link && (options?.IsParamSetAllowed(x) ?? true));
+
+ foreach (var paramSetKey in paramSetKeys)
{
- var descriptions = await device.GetParamSetDescriptionsAsync(paramSetKey).ConfigureAwait(false);
+ try
+ {
+ var descriptions = await device.GetParamSetDescriptionsAsync(paramSetKey).ConfigureAwait(false);
+
+ var descriptionsById = descriptions.Items
+ .Where(x => x.Id is not null)
+ .DistinctBy(x => x.Id)
+ .ToDictionary(x => x.Id!);
+
+ var paramSets = (await device.GetParamSetValuesAsync(paramSetKey).ConfigureAwait(false))
+ .Select(x => new ParamSetValueWithDescription
+ {
+ ParamSetValue = x,
+ Description = descriptionsById.GetValueOrDefault(x.Name)
+ })
+ .ToList();
- var paramSets = (await device.GetParamSetValuesAsync(paramSetKey).ConfigureAwait(false))
- .Select(x => new ParamSetValueWithDescription
+ paramSetValues.Add(new ParamSetValuesWithDescriptions
{
- ParamSetValue = x,
- Description = descriptions.Items.FirstOrDefault(y => y.Id == x.Name) ??
- throw new KeyNotFoundException()
+ ParamSetKey = paramSetKey,
+ ParamSetValues = paramSets
});
-
- paramSetValues.Add(new ParamSetValuesWithDescriptions
+ }
+ catch (Exception exception) when (exception is FaultException or CcuXmlRpcException)
{
- ParamSetKey = paramSetKey,
- ParamSetValues = paramSets
- });
+ paramSetValues.Add(new ParamSetValuesWithDescriptions
+ {
+ ParamSetKey = paramSetKey,
+ ParamSetValues = [],
+ ReadError = BuildReadErrorMessage(exception)
+ });
+ }
}
return [..paramSetValues];
}
+
+ private static string BuildReadErrorMessage(Exception exception)
+ {
+ // Fault codes -1..-10 arrive as typed CcuXmlRpcException with a speaking message and the
+ // original FaultException as inner exception; unmapped codes (e.g. -321) arrive raw.
+ if (exception is CcuXmlRpcException ccuXmlRpcException)
+ {
+ return ccuXmlRpcException.InnerException is FaultException innerFaultException
+ ? FormatReadError(innerFaultException, ccuXmlRpcException.Message)
+ : ccuXmlRpcException.Message;
+ }
+
+ var faultException = (FaultException)exception;
+
+ var faultDescription = faultException.FaultCode == FaultCodeDeviceNotReachable
+ ? "device not reachable (e.g. sleeping battery-powered device)"
+ : null;
+
+ return FormatReadError(faultException, faultDescription);
+ }
+
+ private static string FormatReadError(FaultException faultException, string? description)
+ {
+ var message = description is null
+ ? $"XML-RPC fault {faultException.FaultCode}"
+ : $"XML-RPC fault {faultException.FaultCode} ({description})";
+
+ return string.IsNullOrEmpty(faultException.FaultMessage)
+ ? message
+ : $"{message}: {faultException.FaultMessage}";
+ }
}
diff --git a/source/CreativeCoders.HomeMatic/CreativeCoders.HomeMatic.csproj b/source/CreativeCoders.HomeMatic/CreativeCoders.HomeMatic.csproj
index 6a1e4af..25cef63 100644
--- a/source/CreativeCoders.HomeMatic/CreativeCoders.HomeMatic.csproj
+++ b/source/CreativeCoders.HomeMatic/CreativeCoders.HomeMatic.csproj
@@ -6,18 +6,19 @@
-
+
+
-
-
+
+
-
-
-
+
+
+
diff --git a/source/CreativeCoders.HomeMatic/Exporting/DeviceExportOptions.cs b/source/CreativeCoders.HomeMatic/Exporting/DeviceExportOptions.cs
index d17267d..504f030 100644
--- a/source/CreativeCoders.HomeMatic/Exporting/DeviceExportOptions.cs
+++ b/source/CreativeCoders.HomeMatic/Exporting/DeviceExportOptions.cs
@@ -47,12 +47,7 @@ public class DeviceExportOptions
/// true if the key is allowed or no whitelist is configured; otherwise false.
public bool IsParamSetAllowed(string paramSetKey)
{
- if (ParamSetWhitelist is null || ParamSetWhitelist.Count == 0)
- {
- return true;
- }
-
- return ParamSetWhitelist.Contains(paramSetKey, StringComparer.OrdinalIgnoreCase);
+ return WhitelistFilter.IsAllowed(ParamSetWhitelist, paramSetKey);
}
///
@@ -62,24 +57,21 @@ public bool IsParamSetAllowed(string paramSetKey)
/// true if the name is allowed or no whitelist is configured; otherwise false.
public bool IsParamValueNameAllowed(string paramValueName)
{
- if (ParamValueNameWhitelist is null || ParamValueNameWhitelist.Count == 0)
- {
- return true;
- }
-
- return ParamValueNameWhitelist.Contains(paramValueName, StringComparer.OrdinalIgnoreCase);
+ return WhitelistFilter.IsAllowed(ParamValueNameWhitelist, paramValueName);
}
///
/// Builds a matching this export configuration.
///
- /// A that includes links iff is set.
+ /// A that includes links iff is set
+ /// and forwards the so filtered ParamSets are not fetched at all.
public CompleteCcuDeviceBuildOptions ToBuildOptions()
{
return new CompleteCcuDeviceBuildOptions
{
IncludeLinks = IncludeLinks,
- LinksFlags = LinksFlags
+ LinksFlags = LinksFlags,
+ ParamSetWhitelist = ParamSetWhitelist
};
}
}
diff --git a/source/CreativeCoders.HomeMatic/Exporting/DeviceExporter.cs b/source/CreativeCoders.HomeMatic/Exporting/DeviceExporter.cs
index 4ff5800..97d8120 100644
--- a/source/CreativeCoders.HomeMatic/Exporting/DeviceExporter.cs
+++ b/source/CreativeCoders.HomeMatic/Exporting/DeviceExporter.cs
@@ -88,9 +88,10 @@ private static ParamSetExportData[] BuildParamSetExportData(
.Select(v => new ParamValueExportData
{
Key = v.ParamSetValue.Name,
- Name = v.Description.Id == v.ParamSetValue.Name ? null : v.Description.Id,
+ Name = v.Description?.Id == v.ParamSetValue.Name ? null : v.Description?.Id,
Value = v.ParamSetValue.Value
- }).ToList()
+ }).ToList(),
+ Error = ps.ReadError
})
.ToArray();
}
diff --git a/source/CreativeCoders.HomeMatic/Exporting/ParamSetExportData.cs b/source/CreativeCoders.HomeMatic/Exporting/ParamSetExportData.cs
index d57f8d4..da48182 100644
--- a/source/CreativeCoders.HomeMatic/Exporting/ParamSetExportData.cs
+++ b/source/CreativeCoders.HomeMatic/Exporting/ParamSetExportData.cs
@@ -16,4 +16,10 @@ public class ParamSetExportData
///
/// The enumerable of entries.
public required IEnumerable Values { get; init; }
+
+ ///
+ /// Gets the error message if reading the parameter-set values from the CCU failed.
+ ///
+ /// The error message, or if the values were read successfully.
+ public string? Error { get; init; }
}
diff --git a/source/CreativeCoders.HomeMatic/MultiCcuClient.cs b/source/CreativeCoders.HomeMatic/MultiCcuClient.cs
index eb70a08..48a7647 100644
--- a/source/CreativeCoders.HomeMatic/MultiCcuClient.cs
+++ b/source/CreativeCoders.HomeMatic/MultiCcuClient.cs
@@ -76,7 +76,7 @@ private async Task InvokeWithRoutingAsync(string address,
{
return await func(cachedClient, address).ConfigureAwait(false);
}
- catch (KeyNotFoundException)
+ catch (DeviceNotFoundException)
{
// Cached mapping is stale; drop it and fall back to probing the remaining clients.
_routingTable.Invalidate(deviceAddress);
@@ -93,13 +93,13 @@ private async Task InvokeWithRoutingAsync(string address,
return result;
}
- catch (KeyNotFoundException)
+ catch (DeviceNotFoundException)
{
// Device not found on this client; try the next one.
}
}
- throw new KeyNotFoundException($"Device with address '{address}' not found.");
+ throw new DeviceNotFoundException(address);
}
private void RegisterRoutes(IEnumerable<(string Address, ICcuClient Client)> entries)
diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/ShowDetails/ShowDeviceDetailsCommand.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/ShowDetails/ShowDeviceDetailsCommand.cs
index adeae13..260eb42 100644
--- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/ShowDetails/ShowDeviceDetailsCommand.cs
+++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/ShowDetails/ShowDeviceDetailsCommand.cs
@@ -26,7 +26,10 @@ public async Task ExecuteAsync(ShowDeviceDetailsOptions options)
{
var exportOptions = new DeviceExportOptions
{
- IncludeLinks = true
+ IncludeLinks = true,
+ ParamSetWhitelist = string.IsNullOrWhiteSpace(options.ParamSets)
+ ? null
+ : options.ParamSets.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
};
var device = await _multiCcuClient
@@ -38,6 +41,15 @@ public async Task ExecuteAsync(ShowDeviceDetailsOptions options)
_console.WriteLine($"Show device details for '{options.Address}'");
_console.WriteLine();
+ if (exportOptions.ParamSetWhitelist is { Count: > 0 }
+ && !exportData.ParamSetValues.Any()
+ && exportData.Channels.All(x => !x.ParamSetValues.Any())
+ && (exportData.ParamSetKeys.Length > 0 || exportData.Channels.Any(x => x.ParamSets.Any())))
+ {
+ _console.MarkupLine("[yellow]No ParamSets matched the --param-sets filter.[/]");
+ _console.WriteLine();
+ }
+
PrintDevice(exportData);
return CommandResult.Success;
@@ -87,6 +99,14 @@ private void PrintParamSets(IEnumerable paramSets, string in
{
_console.WriteLine($"{indent}- ParamSet: {paramSet.ParamSetKey}");
+ if (paramSet.Error is not null)
+ {
+ _console.MarkupLine(
+ $"{indent} [yellow]Values could not be read: {Markup.Escape(paramSet.Error)}[/]");
+
+ continue;
+ }
+
foreach (var value in paramSet.Values)
{
var label = value.Name is not null && !string.Equals(value.Name, value.Key, StringComparison.Ordinal)
diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/ShowDetails/ShowDeviceDetailsOptions.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/ShowDetails/ShowDeviceDetailsOptions.cs
index 91c3295..4315708 100644
--- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/ShowDetails/ShowDeviceDetailsOptions.cs
+++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/ShowDetails/ShowDeviceDetailsOptions.cs
@@ -9,4 +9,8 @@ public class ShowDeviceDetailsOptions : CliCommandOptionsBase
{
[OptionValue(0, IsRequired = true)]
public string Address { get; set; } = string.Empty;
+
+ [OptionParameter('p', "param-sets",
+ HelpText = "Comma-separated list of ParamSet keys to show (e.g. MASTER,VALUES). If omitted, all ParamSets are shown")]
+ public string ParamSets { get; set; } = string.Empty;
}
diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Hmc/CreativeCoders.HomeMatic.Tools.Cli.Hmc.csproj b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Hmc/CreativeCoders.HomeMatic.Tools.Cli.Hmc.csproj
index e73d20b..f1ab0a3 100644
--- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Hmc/CreativeCoders.HomeMatic.Tools.Cli.Hmc.csproj
+++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Hmc/CreativeCoders.HomeMatic.Tools.Cli.Hmc.csproj
@@ -13,13 +13,14 @@
-
-
+
+
+
-
-
+
+
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 f1d5ec4..af62a5b 100644
--- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Hmc/Program.cs
+++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Hmc/Program.cs
@@ -7,7 +7,10 @@
using CreativeCoders.HomeMatic.Tools.Cli.Base;
using CreativeCoders.HomeMatic.Tools.Cli.Commands.Connection.Add;
using CreativeCoders.HomeMatic.XmlRpc;
+using CreativeCoders.HomeMatic.XmlRpc.Exceptions;
+using CreativeCoders.Net.XmlRpc.Exceptions;
using Microsoft.Extensions.DependencyInjection;
+using Spectre.Console;
namespace CreativeCoders.HomeMatic.Tools.Cli.Hmc;
@@ -15,22 +18,31 @@ internal static class Program
{
internal static async Task Main(string[] args)
{
- var result = await CliHostBuilder.Create()
- .ConfigureServices(ConfigureServices)
- .PrintFooterText([string.Empty])
- .UseValidation()
- .UseConfiguration(x => x.TryAddJsonFile(
- FileSys.Path.Combine(
- Env.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
- HomeMaticToolApp.ConfigFolderName,
- HomeMaticToolApp.ConfigFileName)))
- .EnableHelp(HelpCommandKind.CommandOrArgument, HelpCommandKind.EmptyArgs)
- .ScanAssemblies(typeof(AddConnectionCommand).Assembly)
- .Build()
- .RunAsync(args)
- .ConfigureAwait(false);
-
- return result.ExitCode;
+ try
+ {
+ var result = await CliHostBuilder.Create()
+ .ConfigureServices(ConfigureServices)
+ .PrintFooterText([string.Empty])
+ .UseValidation()
+ .UseConfiguration(x => x.TryAddJsonFile(
+ FileSys.Path.Combine(
+ Env.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ HomeMaticToolApp.ConfigFolderName,
+ HomeMaticToolApp.ConfigFileName)))
+ .EnableHelp(HelpCommandKind.CommandOrArgument, HelpCommandKind.EmptyArgs)
+ .ScanAssemblies(typeof(AddConnectionCommand).Assembly)
+ .Build()
+ .RunAsync(args)
+ .ConfigureAwait(false);
+
+ return result.ExitCode;
+ }
+ catch (Exception ex) when (ex is FaultException or HomeMaticException or HttpRequestException)
+ {
+ AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]");
+
+ return 1;
+ }
}
private static void ConfigureServices(IServiceCollection services)
diff --git a/tests/CreativeCoders.HomeMatic.Tests/CcuClientTests.cs b/tests/CreativeCoders.HomeMatic.Tests/CcuClientTests.cs
index 60ebebe..b2d6c41 100644
--- a/tests/CreativeCoders.HomeMatic.Tests/CcuClientTests.cs
+++ b/tests/CreativeCoders.HomeMatic.Tests/CcuClientTests.cs
@@ -257,7 +257,7 @@ public async Task GetDevicesAsync_EmptyDeviceDetails_ReturnsDevicesWithEmptyName
}
[Fact]
- public async Task GetDeviceAsync_UnknownAddress_ThrowsKeyNotFoundException()
+ public async Task GetDeviceAsync_UnknownAddress_ThrowsDeviceNotFoundException()
{
// Arrange
var jsonRpcClient = A.Fake();
@@ -277,7 +277,7 @@ public async Task GetDeviceAsync_UnknownAddress_ThrowsKeyNotFoundException()
var act = () => ccuClient.GetDeviceAsync("UNKNOWN");
// Assert
- await act.Should().ThrowAsync()
+ await act.Should().ThrowAsync()
.WithMessage("Device with address 'UNKNOWN' not found.");
}
@@ -441,7 +441,7 @@ public async Task GetCompleteDeviceAsync_KnownAddress_ReturnsBuilderResult()
}
[Fact]
- public async Task GetCompleteDeviceAsync_UnknownAddress_ThrowsKeyNotFoundException()
+ public async Task GetCompleteDeviceAsync_UnknownAddress_ThrowsDeviceNotFoundException()
{
// Arrange
var jsonRpcClient = A.Fake();
@@ -461,7 +461,7 @@ public async Task GetCompleteDeviceAsync_UnknownAddress_ThrowsKeyNotFoundExcepti
var act = () => ccuClient.GetCompleteDeviceAsync("UNKNOWN");
// Assert
- await act.Should().ThrowAsync();
+ await act.Should().ThrowAsync();
}
[Fact]
diff --git a/tests/CreativeCoders.HomeMatic.Tests/CcuDeviceTests.cs b/tests/CreativeCoders.HomeMatic.Tests/CcuDeviceTests.cs
index f54c0cc..888ed49 100644
--- a/tests/CreativeCoders.HomeMatic.Tests/CcuDeviceTests.cs
+++ b/tests/CreativeCoders.HomeMatic.Tests/CcuDeviceTests.cs
@@ -29,7 +29,7 @@ public async Task GetChannelAsync_WithMatchingAddress_ReturnsChannel()
}
[Fact]
- public async Task GetChannelAsync_WithUnknownAddress_ThrowsKeyNotFoundException()
+ public async Task GetChannelAsync_WithUnknownAddress_ThrowsDeviceNotFoundException()
{
// Arrange
var api = A.Fake();
@@ -39,12 +39,12 @@ public async Task GetChannelAsync_WithUnknownAddress_ThrowsKeyNotFoundException(
var act = () => device.GetChannelAsync("DEV:UNKNOWN");
// Assert
- await act.Should().ThrowAsync()
+ await act.Should().ThrowAsync()
.WithMessage("Channel with address 'DEV:UNKNOWN' not found.");
}
[Fact]
- public async Task GetChannelAsync_WithNoChannels_ThrowsKeyNotFoundException()
+ public async Task GetChannelAsync_WithNoChannels_ThrowsDeviceNotFoundException()
{
// Arrange
var api = A.Fake();
@@ -54,7 +54,7 @@ public async Task GetChannelAsync_WithNoChannels_ThrowsKeyNotFoundException()
var act = () => device.GetChannelAsync("DEV:1");
// Assert
- await act.Should().ThrowAsync();
+ await act.Should().ThrowAsync();
}
private static CcuDevice CreateDevice(IHomeMaticXmlRpcApi api, IEnumerable channels)
diff --git a/tests/CreativeCoders.HomeMatic.Tests/CompleteCcuDeviceBuildOptionsTests.cs b/tests/CreativeCoders.HomeMatic.Tests/CompleteCcuDeviceBuildOptionsTests.cs
new file mode 100644
index 0000000..71e47b4
--- /dev/null
+++ b/tests/CreativeCoders.HomeMatic.Tests/CompleteCcuDeviceBuildOptionsTests.cs
@@ -0,0 +1,72 @@
+using CreativeCoders.HomeMatic.Core;
+using AwesomeAssertions;
+
+namespace CreativeCoders.HomeMatic.Tests;
+
+public class CompleteCcuDeviceBuildOptionsTests
+{
+ [Fact]
+ public void IsParamSetAllowed_NoWhitelistConfigured_ReturnsTrue()
+ {
+ // Arrange
+ var options = new CompleteCcuDeviceBuildOptions();
+
+ // Act
+ var result = options.IsParamSetAllowed("SERVICE");
+
+ // Assert
+ result.Should().BeTrue();
+ }
+
+ [Fact]
+ public void IsParamSetAllowed_EmptyWhitelist_ReturnsTrue()
+ {
+ // Arrange
+ var options = new CompleteCcuDeviceBuildOptions
+ {
+ ParamSetWhitelist = []
+ };
+
+ // Act
+ var result = options.IsParamSetAllowed("SERVICE");
+
+ // Assert
+ result.Should().BeTrue();
+ }
+
+ [Fact]
+ public void IsParamSetAllowed_WhitelistEntryInDifferentCase_ReturnsTrue()
+ {
+ // Arrange
+ var options = new CompleteCcuDeviceBuildOptions
+ {
+ ParamSetWhitelist = ["master"]
+ };
+
+ // Act
+ var result = options.IsParamSetAllowed("MASTER");
+
+ // Assert
+ result.Should().BeTrue();
+ }
+
+ [Theory]
+ [InlineData("MASTER", true)]
+ [InlineData("master", true)]
+ [InlineData("Values", true)]
+ [InlineData("SERVICE", false)]
+ public void IsParamSetAllowed_WhitelistConfigured_ReturnsExpected(string paramSetKey, bool expected)
+ {
+ // Arrange
+ var options = new CompleteCcuDeviceBuildOptions
+ {
+ ParamSetWhitelist = ["MASTER", "VALUES"]
+ };
+
+ // Act
+ var result = options.IsParamSetAllowed(paramSetKey);
+
+ // Assert
+ result.Should().Be(expected);
+ }
+}
diff --git a/tests/CreativeCoders.HomeMatic.Tests/CompleteCcuDeviceBuilderTests.cs b/tests/CreativeCoders.HomeMatic.Tests/CompleteCcuDeviceBuilderTests.cs
index f56484b..75d63d0 100644
--- a/tests/CreativeCoders.HomeMatic.Tests/CompleteCcuDeviceBuilderTests.cs
+++ b/tests/CreativeCoders.HomeMatic.Tests/CompleteCcuDeviceBuilderTests.cs
@@ -1,6 +1,8 @@
using CreativeCoders.HomeMatic.Core;
using CreativeCoders.HomeMatic.Core.Devices;
+using CreativeCoders.HomeMatic.XmlRpc.Exceptions;
using CreativeCoders.HomeMatic.XmlRpc.Links;
+using CreativeCoders.Net.XmlRpc.Exceptions;
using FakeItEasy;
using AwesomeAssertions;
@@ -73,7 +75,7 @@ public async Task BuildAsync_SkipsLinkParamSetKey()
}
[Fact]
- public async Task BuildAsync_WhenValueHasNoMatchingDescription_ThrowsKeyNotFoundException()
+ public async Task BuildAsync_WhenValueHasNoMatchingDescription_ReturnsValueWithNullDescription()
{
// Arrange
var device = A.Fake();
@@ -81,7 +83,7 @@ public async Task BuildAsync_WhenValueHasNoMatchingDescription_ThrowsKeyNotFound
A.CallTo(() => device.Channels).Returns([]);
A.CallTo(() => device.ParamSets).Returns(["MASTER"]);
- // Values contain "A", but the description list is empty -> FirstOrDefault returns null -> throw.
+ // Values contain "A", but the description list is empty -> Description stays null.
A.CallTo(() => device.GetParamSetValuesAsync("MASTER"))
.Returns(Task.FromResult>(
[
@@ -98,18 +100,12 @@ public async Task BuildAsync_WhenValueHasNoMatchingDescription_ThrowsKeyNotFound
var builder = new CompleteCcuDeviceBuilder();
// Act
- var act = async () =>
- {
- var result = await builder.BuildAsync(device);
- // ParamSetValues uses deferred execution via Select; force enumeration.
- foreach (var paramSet in result.ParamSetValues)
- {
- _ = paramSet.ParamSetValues.ToList();
- }
- };
+ var completeDevice = await builder.BuildAsync(device);
// Assert
- await act.Should().ThrowAsync();
+ var paramSetValue = completeDevice.ParamSetValues.Single().ParamSetValues.Single();
+ paramSetValue.ParamSetValue.Name.Should().Be("A");
+ paramSetValue.Description.Should().BeNull();
}
[Fact]
@@ -261,6 +257,275 @@ public async Task BuildAsync_WithIncludeLinksFalse_DoesNotFetchLinks()
.MustNotHaveHappened();
}
+ [Fact]
+ public async Task BuildAsync_WhenParamSetValuesFault_ReturnsParamSetWithReadErrorInsteadOfThrowing()
+ {
+ // Arrange
+ var device = A.Fake();
+
+ A.CallTo(() => device.Channels).Returns([]);
+ A.CallTo(() => device.ParamSets).Returns(["MASTER", "SERVICE"]);
+
+ SetupParamSet(device, "MASTER", "AES_ACTIVE", true);
+
+ A.CallTo(() => device.GetParamSetDescriptionsAsync("SERVICE"))
+ .Returns(Task.FromResult(new CcuParameterDescriptions
+ {
+ ParamSetKey = "SERVICE",
+ Items = []
+ }));
+ A.CallTo(() => device.GetParamSetValuesAsync("SERVICE"))
+ .Throws(new FaultException(-321, string.Empty));
+
+ var builder = new CompleteCcuDeviceBuilder();
+
+ // Act
+ var completeDevice = await builder.BuildAsync(device);
+
+ // Assert
+ var paramSets = completeDevice.ParamSetValues.ToList();
+ paramSets.Should().HaveCount(2);
+
+ var masterParamSet = paramSets.Single(x => x.ParamSetKey == "MASTER");
+ masterParamSet.ReadError.Should().BeNull();
+ masterParamSet.ParamSetValues.Should().ContainSingle();
+
+ var serviceParamSet = paramSets.Single(x => x.ParamSetKey == "SERVICE");
+ serviceParamSet.ReadError.Should()
+ .Be("XML-RPC fault -321 (device not reachable (e.g. sleeping battery-powered device))");
+ serviceParamSet.ParamSetValues.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task BuildAsync_WhenParamSetDescriptionsFault_ReturnsParamSetWithReadError()
+ {
+ // Arrange
+ var device = A.Fake();
+
+ A.CallTo(() => device.Channels).Returns([]);
+ A.CallTo(() => device.ParamSets).Returns(["MASTER"]);
+
+ A.CallTo(() => device.GetParamSetDescriptionsAsync("MASTER"))
+ .Throws(new GeneralException("General exception",
+ new FaultException(-1, "Generic error (TRANSACTION_DISCARDED_FOR_UNREACHABLE_DEVICE)")));
+
+ var builder = new CompleteCcuDeviceBuilder();
+
+ // Act
+ var completeDevice = await builder.BuildAsync(device);
+
+ // Assert
+ var paramSet = completeDevice.ParamSetValues.Single();
+ paramSet.ReadError.Should()
+ .Be("XML-RPC fault -1 (General exception): Generic error (TRANSACTION_DISCARDED_FOR_UNREACHABLE_DEVICE)");
+ paramSet.ParamSetValues.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task BuildAsync_WhenChannelParamSetFaults_ReturnsChannelParamSetWithReadError()
+ {
+ // Arrange
+ var device = A.Fake();
+ var channel = A.Fake();
+
+ A.CallTo(() => device.Channels).Returns([channel]);
+ A.CallTo(() => device.ParamSets).Returns([]);
+ A.CallTo(() => channel.ParamSets).Returns(["VALUES"]);
+
+ A.CallTo(() => channel.GetParamSetDescriptionsAsync("VALUES"))
+ .Throws(new FaultException(-9999, string.Empty));
+
+ var builder = new CompleteCcuDeviceBuilder();
+
+ // Act
+ var completeDevice = await builder.BuildAsync(device);
+
+ // Assert - unknown fault code is rendered without a speaking description.
+ var paramSet = completeDevice.Channels.Single().ParamSetValues.Single();
+ paramSet.ReadError.Should().Be("XML-RPC fault -9999");
+ paramSet.ParamSetValues.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task BuildAsync_WithParamSetWhitelist_FetchesOnlyWhitelistedParamSets()
+ {
+ // Arrange
+ var device = A.Fake();
+ var channel = A.Fake();
+
+ A.CallTo(() => device.Channels).Returns([channel]);
+ A.CallTo(() => device.ParamSets).Returns(["MASTER", "SERVICE"]);
+ A.CallTo(() => channel.ParamSets).Returns(["MASTER", "VALUES", "SERVICE"]);
+
+ SetupParamSet(device, "MASTER", "A", 1);
+ SetupParamSet(channel, "MASTER", "B", 2);
+ SetupParamSet(channel, "VALUES", "C", 3);
+
+ var builder = new CompleteCcuDeviceBuilder();
+ var options = new CompleteCcuDeviceBuildOptions
+ {
+ ParamSetWhitelist = ["MASTER", "VALUES"]
+ };
+
+ // Act
+ var completeDevice = await builder.BuildAsync(device, options);
+
+ // Assert - SERVICE must not be requested at all.
+ A.CallTo(() => device.GetParamSetValuesAsync("SERVICE")).MustNotHaveHappened();
+ A.CallTo(() => device.GetParamSetDescriptionsAsync("SERVICE")).MustNotHaveHappened();
+ A.CallTo(() => channel.GetParamSetValuesAsync("SERVICE")).MustNotHaveHappened();
+ A.CallTo(() => channel.GetParamSetDescriptionsAsync("SERVICE")).MustNotHaveHappened();
+
+ completeDevice.ParamSetValues.Select(x => x.ParamSetKey)
+ .Should().BeEquivalentTo("MASTER");
+ completeDevice.Channels.Single().ParamSetValues.Select(x => x.ParamSetKey)
+ .Should().BeEquivalentTo("MASTER", "VALUES");
+ }
+
+ [Fact]
+ public async Task BuildAsync_WhenParamSetReadThrowsNonFaultException_PropagatesException()
+ {
+ // Arrange
+ var device = A.Fake();
+
+ A.CallTo(() => device.Channels).Returns([]);
+ A.CallTo(() => device.ParamSets).Returns(["MASTER"]);
+
+ A.CallTo(() => device.GetParamSetDescriptionsAsync("MASTER"))
+ .Throws(new InvalidOperationException("connection lost"));
+
+ var builder = new CompleteCcuDeviceBuilder();
+
+ // Act
+ var act = async () => await builder.BuildAsync(device);
+
+ // Assert - only FaultException is converted to ReadError, everything else propagates.
+ await act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task BuildAsync_WhitelistContainingLink_StillExcludesLinkParamSet()
+ {
+ // Arrange
+ var device = A.Fake();
+
+ A.CallTo(() => device.Channels).Returns([]);
+ A.CallTo(() => device.ParamSets).Returns(["MASTER", "LINK"]);
+
+ SetupParamSet(device, "MASTER", "A", 1);
+
+ var builder = new CompleteCcuDeviceBuilder();
+ var options = new CompleteCcuDeviceBuildOptions
+ {
+ ParamSetWhitelist = ["MASTER", "LINK"]
+ };
+
+ // Act
+ var completeDevice = await builder.BuildAsync(device, options);
+
+ // Assert
+ A.CallTo(() => device.GetParamSetValuesAsync("LINK")).MustNotHaveHappened();
+ A.CallTo(() => device.GetParamSetDescriptionsAsync("LINK")).MustNotHaveHappened();
+ completeDevice.ParamSetValues.Select(x => x.ParamSetKey).Should().BeEquivalentTo("MASTER");
+ }
+
+ public static TheoryData FaultCases => new()
+ {
+ {
+ new GeneralException("General exception",
+ new FaultException(-1, "Generic error (TRANSACTION_DISCARDED_FOR_UNREACHABLE_DEVICE)")),
+ "XML-RPC fault -1 (General exception): Generic error (TRANSACTION_DISCARDED_FOR_UNREACHABLE_DEVICE)"
+ },
+ {
+ new UnknownDeviceOrChannelException("Device or channel unknown", new FaultException(-2, string.Empty)),
+ "XML-RPC fault -2 (Device or channel unknown)"
+ },
+ {
+ new UnknownParamSetException("ParamSet unknown", new FaultException(-3, string.Empty)),
+ "XML-RPC fault -3 (ParamSet unknown)"
+ },
+ {
+ new FaultException(-321, string.Empty),
+ "XML-RPC fault -321 (device not reachable (e.g. sleeping battery-powered device))"
+ },
+ { new FaultException(-9999, string.Empty), "XML-RPC fault -9999" },
+ { new FaultException(-42, "boom"), "XML-RPC fault -42: boom" }
+ };
+
+ [Theory]
+ [MemberData(nameof(FaultCases))]
+ public async Task BuildAsync_WhenParamSetFaults_MapsFaultCodeToReadError(Exception thrownException,
+ string expectedReadError)
+ {
+ // Arrange
+ var device = A.Fake();
+
+ A.CallTo(() => device.Channels).Returns([]);
+ A.CallTo(() => device.ParamSets).Returns(["MASTER"]);
+
+ A.CallTo(() => device.GetParamSetDescriptionsAsync("MASTER"))
+ .Throws(thrownException);
+
+ var builder = new CompleteCcuDeviceBuilder();
+
+ // Act
+ var completeDevice = await builder.BuildAsync(device);
+
+ // Assert
+ completeDevice.ParamSetValues.Single().ReadError.Should().Be(expectedReadError);
+ }
+
+ [Fact]
+ public async Task BuildAsync_WithPartiallyMatchingDescriptions_AssignsDescriptionsPerValue()
+ {
+ // Arrange
+ var device = A.Fake();
+
+ A.CallTo(() => device.Channels).Returns([]);
+ A.CallTo(() => device.ParamSets).Returns(["MASTER"]);
+
+ A.CallTo(() => device.GetParamSetValuesAsync("MASTER"))
+ .Returns(Task.FromResult>(
+ [
+ new ParamSetValue { Name = "DESCRIBED", Value = 1 },
+ new ParamSetValue { Name = "UNDESCRIBED", Value = 2 }
+ ]));
+
+ A.CallTo(() => device.GetParamSetDescriptionsAsync("MASTER"))
+ .Returns(Task.FromResult(new CcuParameterDescriptions
+ {
+ ParamSetKey = "MASTER",
+ Items =
+ [
+ new CcuParameterDescription
+ {
+ Id = "DESCRIBED",
+ DefaultValue = null,
+ MinValue = null,
+ MaxValue = null,
+ Type = null,
+ DataType = default,
+ Unit = null,
+ TabOrder = 0,
+ Control = null,
+ ValuesList = [],
+ SpecialValues = []
+ }
+ ]
+ }));
+
+ var builder = new CompleteCcuDeviceBuilder();
+
+ // Act
+ var completeDevice = await builder.BuildAsync(device);
+
+ // Assert
+ var values = completeDevice.ParamSetValues.Single().ParamSetValues.ToList();
+ values.Should().HaveCount(2);
+ values.Single(x => x.ParamSetValue.Name == "DESCRIBED").Description.Should().NotBeNull();
+ values.Single(x => x.ParamSetValue.Name == "UNDESCRIBED").Description.Should().BeNull();
+ }
+
private static void SetupParamSet(ICcuDeviceBase device, string paramSetKey, string name, object value)
{
A.CallTo(() => device.GetParamSetValuesAsync(paramSetKey))
diff --git a/tests/CreativeCoders.HomeMatic.Tests/CreativeCoders.HomeMatic.Tests.csproj b/tests/CreativeCoders.HomeMatic.Tests/CreativeCoders.HomeMatic.Tests.csproj
index 55ebc21..036697d 100644
--- a/tests/CreativeCoders.HomeMatic.Tests/CreativeCoders.HomeMatic.Tests.csproj
+++ b/tests/CreativeCoders.HomeMatic.Tests/CreativeCoders.HomeMatic.Tests.csproj
@@ -9,11 +9,12 @@
+
-
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
@@ -26,12 +27,12 @@
-
+
-
-
+
+
diff --git a/tests/CreativeCoders.HomeMatic.Tests/Exporting/CompleteCcuDeviceChannelFakeBuilder.cs b/tests/CreativeCoders.HomeMatic.Tests/Exporting/CompleteCcuDeviceChannelFakeBuilder.cs
index 235722e..548270b 100644
--- a/tests/CreativeCoders.HomeMatic.Tests/Exporting/CompleteCcuDeviceChannelFakeBuilder.cs
+++ b/tests/CreativeCoders.HomeMatic.Tests/Exporting/CompleteCcuDeviceChannelFakeBuilder.cs
@@ -52,7 +52,8 @@ public CompleteCcuDeviceChannelFakeBuilder WithCcuHost(string ccuHost)
return this;
}
- public CompleteCcuDeviceChannelFakeBuilder WithParamSet(string paramSetKey, Action? configure = null)
+ public CompleteCcuDeviceChannelFakeBuilder WithParamSet(string paramSetKey, Action? configure = null,
+ string? readError = null)
{
var builder = new ParamSetValuesBuilder();
configure?.Invoke(builder);
@@ -60,7 +61,8 @@ public CompleteCcuDeviceChannelFakeBuilder WithParamSet(string paramSetKey, Acti
_paramSetValues.Add(new ParamSetValuesWithDescriptions
{
ParamSetKey = paramSetKey,
- ParamSetValues = builder.Build().ToList()
+ ParamSetValues = builder.Build().ToList(),
+ ReadError = readError
});
return this;
diff --git a/tests/CreativeCoders.HomeMatic.Tests/Exporting/CompleteCcuDeviceFakeBuilder.cs b/tests/CreativeCoders.HomeMatic.Tests/Exporting/CompleteCcuDeviceFakeBuilder.cs
index ed15345..af27bbe 100644
--- a/tests/CreativeCoders.HomeMatic.Tests/Exporting/CompleteCcuDeviceFakeBuilder.cs
+++ b/tests/CreativeCoders.HomeMatic.Tests/Exporting/CompleteCcuDeviceFakeBuilder.cs
@@ -59,7 +59,8 @@ public CompleteCcuDeviceFakeBuilder WithParamSetKeys(params string[] paramSetKey
return this;
}
- public CompleteCcuDeviceFakeBuilder WithParamSet(string paramSetKey, Action? configure = null)
+ public CompleteCcuDeviceFakeBuilder WithParamSet(string paramSetKey, Action? configure = null,
+ string? readError = null)
{
var builder = new ParamSetValuesBuilder();
configure?.Invoke(builder);
@@ -67,7 +68,8 @@ public CompleteCcuDeviceFakeBuilder WithParamSet(string paramSetKey, Action p.AddWithoutDescription("ALARM_MODE_TYPE", 0))
+ .Build();
+ var sut = new DeviceExporter();
+
+ // Act
+ var result = sut.BuildExportData(device);
+
+ // Assert
+ var value = result.ParamSetValues.Single().Values.Single();
+ value.Key.Should().Be("ALARM_MODE_TYPE");
+ value.Name.Should().BeNull();
+ value.Value.Should().Be(0);
+ }
+
+ [Fact]
+ public void BuildExportData_WithReadError_MapsErrorToParamSetExportData()
+ {
+ // Arrange
+ const string readError = "XML-RPC fault -321 (device not reachable (e.g. sleeping battery-powered device))";
+
+ var device = new CompleteCcuDeviceFakeBuilder()
+ .WithParamSet("SERVICE", readError: readError)
+ .WithParamSet("VALUES", p => p.Add("STATE", true, descriptionId: "STATE"))
+ .Build();
+ var sut = new DeviceExporter();
+
+ // Act
+ var result = sut.BuildExportData(device);
+
+ // Assert
+ var serviceParamSet = result.ParamSetValues.Single(x => x.ParamSetKey == "SERVICE");
+ serviceParamSet.Error.Should().Be(readError);
+ serviceParamSet.Values.Should().BeEmpty();
+
+ result.ParamSetValues.Single(x => x.ParamSetKey == "VALUES").Error.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task ExportDeviceAsync_WithReadError_EmitsErrorPropertyInJson()
+ {
+ // Arrange
+ var device = new CompleteCcuDeviceFakeBuilder()
+ .WithParamSet("SERVICE", readError: "XML-RPC fault -321")
+ .Build();
+ var sut = new DeviceExporter();
+
+ // Act
+ var json = await sut.ExportDeviceAsync(device);
+
+ // Assert
+ json.Should().Contain("\"error\"").And.Contain("XML-RPC fault -321");
+ }
+
+ [Fact]
+ public void BuildExportData_WithChannelReadError_MapsErrorToChannelParamSetExportData()
+ {
+ // Arrange
+ const string readError = "XML-RPC fault -321";
+
+ var device = new CompleteCcuDeviceFakeBuilder()
+ .WithChannel(c => c.WithParamSet("SERVICE", readError: readError))
+ .Build();
+ var sut = new DeviceExporter();
+
+ // Act
+ var result = sut.BuildExportData(device);
+
+ // Assert
+ var channelParamSet = result.Channels.Single().ParamSetValues.Single();
+ channelParamSet.Error.Should().Be(readError);
+ channelParamSet.Values.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task ExportDeviceAsync_WithoutReadError_OmitsErrorPropertyFromJson()
+ {
+ // Arrange
+ var device = new CompleteCcuDeviceFakeBuilder()
+ .WithParamSet("VALUES", p => p.Add("STATE", true, descriptionId: "STATE"))
+ .Build();
+ var sut = new DeviceExporter();
+
+ // Act
+ var json = await sut.ExportDeviceAsync(device);
+
+ // Assert
+ json.Should().NotContain("\"error\"");
+ }
}
diff --git a/tests/CreativeCoders.HomeMatic.Tests/Exporting/ParamSetValuesBuilder.cs b/tests/CreativeCoders.HomeMatic.Tests/Exporting/ParamSetValuesBuilder.cs
index ff09b9d..d8412a2 100644
--- a/tests/CreativeCoders.HomeMatic.Tests/Exporting/ParamSetValuesBuilder.cs
+++ b/tests/CreativeCoders.HomeMatic.Tests/Exporting/ParamSetValuesBuilder.cs
@@ -31,6 +31,17 @@ public ParamSetValuesBuilder Add(string name, object value, string? descriptionI
return this;
}
+ public ParamSetValuesBuilder AddWithoutDescription(string name, object value)
+ {
+ _values.Add(new ParamSetValueWithDescription
+ {
+ ParamSetValue = new ParamSetValue { Name = name, Value = value },
+ Description = null
+ });
+
+ return this;
+ }
+
public IEnumerable Build()
{
return _values;
diff --git a/tests/CreativeCoders.HomeMatic.Tests/MultiCcuClientTests.cs b/tests/CreativeCoders.HomeMatic.Tests/MultiCcuClientTests.cs
index f1c8ab9..6b5ee17 100644
--- a/tests/CreativeCoders.HomeMatic.Tests/MultiCcuClientTests.cs
+++ b/tests/CreativeCoders.HomeMatic.Tests/MultiCcuClientTests.cs
@@ -1,6 +1,7 @@
using CreativeCoders.HomeMatic.Core;
using CreativeCoders.HomeMatic.Core.Devices;
using CreativeCoders.HomeMatic.XmlRpc;
+using CreativeCoders.HomeMatic.XmlRpc.Exceptions;
using FakeItEasy;
using AwesomeAssertions;
@@ -21,11 +22,54 @@ public async Task GetDeviceAsync_UnknownDevice_ProbesAllClientsAndThrows()
var act = () => multi.GetDeviceAsync("UNKNOWN");
- await act.Should().ThrowAsync();
+ await act.Should().ThrowAsync();
A.CallTo(() => clientA.GetDeviceAsync("UNKNOWN")).MustHaveHappenedOnceExactly();
A.CallTo(() => clientB.GetDeviceAsync("UNKNOWN")).MustHaveHappenedOnceExactly();
}
+ [Fact]
+ public async Task GetDeviceAsync_StaleRouteAndDeviceOnNoClient_ThrowsDeviceNotFoundException()
+ {
+ var clientA = CreateClientWithDevices(DeviceA);
+ var clientB = CreateClientWithDevices(DeviceB);
+
+ var multi = new MultiCcuClient([clientA, clientB], new CcuRoutingTable());
+
+ await multi.GetDeviceAsync(DeviceA);
+
+ // Device vanished from clientA; the cached route is now stale and no other client has it.
+ A.CallTo(() => clientA.GetDeviceAsync(DeviceA)).ThrowsAsync(new DeviceNotFoundException("unknown"));
+
+ var act = () => multi.GetDeviceAsync(DeviceA);
+
+ await act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task GetDeviceAsync_UnknownChannelAddress_ExceptionContainsOriginalAddress()
+ {
+ var clientA = CreateClientWithDevices(DeviceA);
+
+ var multi = new MultiCcuClient([clientA], new CcuRoutingTable());
+
+ var act = () => multi.GetDeviceAsync("UNKNOWN:1");
+
+ (await act.Should().ThrowAsync())
+ .Which.Address.Should().Be("UNKNOWN:1");
+ }
+
+ [Fact]
+ public async Task GetDeviceAsync_UnknownDevice_ExceptionIsCatchableAsHomeMaticException()
+ {
+ var clientA = CreateClientWithDevices();
+
+ var multi = new MultiCcuClient([clientA], new CcuRoutingTable());
+
+ var act = () => multi.GetDeviceAsync("UNKNOWN");
+
+ await act.Should().ThrowAsync();
+ }
+
[Fact]
public async Task GetDeviceAsync_SecondCall_UsesOnlyOwningClient()
{
@@ -73,7 +117,7 @@ public async Task GetDeviceAsync_ChannelAddress_UsesDeviceLevelRoute()
CcuHost = "ccu-b", Kind = CcuDeviceKind.HomeMatic, Address = $"{DeviceB}:1"
});
A.CallTo(() => clientB.GetDeviceAsync($"{DeviceB}:1")).Returns(Task.FromResult(channelDevice));
- A.CallTo(() => clientA.GetDeviceAsync($"{DeviceB}:1")).ThrowsAsync(new KeyNotFoundException());
+ A.CallTo(() => clientA.GetDeviceAsync($"{DeviceB}:1")).ThrowsAsync(new DeviceNotFoundException("unknown"));
var multi = new MultiCcuClient([clientA, clientB], new CcuRoutingTable());
@@ -96,7 +140,7 @@ public async Task GetDeviceAsync_CachedClientFails_FallsBackToOtherClientsAndUpd
var routingTable = new CcuRoutingTable();
// Pre-seed stale mapping: DeviceA owned by clientA even though it actually lives on clientB as well.
// Make clientA fail to simulate a stale entry.
- A.CallTo(() => clientA.GetDeviceAsync(DeviceA)).ThrowsAsync(new KeyNotFoundException());
+ A.CallTo(() => clientA.GetDeviceAsync(DeviceA)).ThrowsAsync(new DeviceNotFoundException("unknown"));
routingTable.Register(DeviceA, clientA);
var multi = new MultiCcuClient([clientA, clientB], routingTable);
@@ -149,7 +193,7 @@ public async Task GetCompleteDeviceAsync_UnknownDevice_ProbesAllClientsAndThrows
var act = () => multi.GetCompleteDeviceAsync("UNKNOWN");
- await act.Should().ThrowAsync();
+ await act.Should().ThrowAsync();
A.CallTo(() => clientA.GetCompleteDeviceAsync("UNKNOWN")).MustHaveHappenedOnceExactly();
A.CallTo(() => clientB.GetCompleteDeviceAsync("UNKNOWN")).MustHaveHappenedOnceExactly();
}
@@ -164,7 +208,7 @@ public async Task GetCompleteDeviceAsync_ChannelAddress_UsesDeviceLevelRoute()
A.CallTo(() => clientB.GetCompleteDeviceAsync($"{DeviceB}:1"))
.Returns(Task.FromResult(channelDevice));
A.CallTo(() => clientA.GetCompleteDeviceAsync($"{DeviceB}:1"))
- .ThrowsAsync(new KeyNotFoundException());
+ .ThrowsAsync(new DeviceNotFoundException("unknown"));
var multi = new MultiCcuClient([clientA, clientB], new CcuRoutingTable());
@@ -186,7 +230,7 @@ public async Task GetCompleteDeviceAsync_CachedClientFails_FallsBackToOtherClien
var routingTable = new CcuRoutingTable();
// Pre-seed stale mapping to clientA, and make clientA throw to simulate stale entry.
- A.CallTo(() => clientA.GetCompleteDeviceAsync(DeviceA)).ThrowsAsync(new KeyNotFoundException());
+ A.CallTo(() => clientA.GetCompleteDeviceAsync(DeviceA)).ThrowsAsync(new DeviceNotFoundException("unknown"));
routingTable.Register(DeviceA, clientA);
var multi = new MultiCcuClient([clientA, clientB], routingTable);
@@ -271,13 +315,13 @@ public async Task GetCompleteDevicesAsync_NoClients_ReturnsEmpty()
}
[Fact]
- public async Task GetCompleteDeviceAsync_NoClients_ThrowsKeyNotFoundException()
+ public async Task GetCompleteDeviceAsync_NoClients_ThrowsDeviceNotFoundException()
{
var multi = new MultiCcuClient([], new CcuRoutingTable());
var act = () => multi.GetCompleteDeviceAsync(DeviceA);
- await act.Should().ThrowAsync();
+ await act.Should().ThrowAsync();
}
[Fact]
@@ -303,7 +347,7 @@ public async Task GetCompleteDeviceAsync_CachedClientThrowsNonKeyNotFound_Propag
[Fact]
public async Task GetCompleteDeviceAsync_ProbingClientThrowsNonKeyNotFound_Propagates()
{
- // Only KeyNotFoundException should trigger the probe fallback; other exceptions must bubble up.
+ // Only DeviceNotFoundException should trigger the probe fallback; other exceptions must bubble up.
var clientA = A.Fake();
var clientB = CreateClientWithCompleteDevices(DeviceA);
A.CallTo(() => clientA.GetCompleteDeviceAsync(DeviceA)).ThrowsAsync(new InvalidOperationException("net"));
@@ -332,9 +376,9 @@ private static ICcuClient CreateClientWithDevices(params string[] addresses)
A.CallTo(() => client.GetDevicesAsync()).Returns(Task.FromResult(devices.AsEnumerable()));
- // Default: unknown addresses throw KeyNotFoundException.
+ // Default: unknown addresses throw DeviceNotFoundException.
A.CallTo(() => client.GetDeviceAsync(A.That.Matches(x => !addresses.Contains(x))))
- .ThrowsAsync(new KeyNotFoundException());
+ .ThrowsAsync(new DeviceNotFoundException("unknown"));
return client;
}
@@ -362,7 +406,7 @@ private static ICcuClient CreateClientWithCompleteDevices(params string[] addres
A.CallTo(() => client.GetCompleteDevicesAsync()).Returns(Task.FromResult(devices.AsEnumerable()));
A.CallTo(() => client.GetCompleteDeviceAsync(A.That.Matches(x => !addresses.Contains(x))))
- .ThrowsAsync(new KeyNotFoundException());
+ .ThrowsAsync(new DeviceNotFoundException("unknown"));
return client;
}
diff --git a/tests/CreativeCoders.HomeMatic.Tools.Cli.Commands.Tests/Device/ShowDetails/ShowDeviceDetailsCommandTests.cs b/tests/CreativeCoders.HomeMatic.Tools.Cli.Commands.Tests/Device/ShowDetails/ShowDeviceDetailsCommandTests.cs
new file mode 100644
index 0000000..d7f768c
--- /dev/null
+++ b/tests/CreativeCoders.HomeMatic.Tools.Cli.Commands.Tests/Device/ShowDetails/ShowDeviceDetailsCommandTests.cs
@@ -0,0 +1,288 @@
+using AwesomeAssertions;
+using CreativeCoders.HomeMatic.Core;
+using CreativeCoders.HomeMatic.Core.Devices;
+using CreativeCoders.HomeMatic.Exporting;
+using CreativeCoders.HomeMatic.Tools.Cli.Commands.Device.ShowDetails;
+using FakeItEasy;
+using Spectre.Console;
+
+namespace CreativeCoders.HomeMatic.Tools.Cli.Commands.Tests.Device.ShowDetails;
+
+public class ShowDeviceDetailsCommandTests
+{
+ private const string DeviceAddress = "00019F2999BE83";
+
+ [Fact]
+ public async Task ExecuteAsync_ParamSetWithError_PrintsWarningInsteadOfValues()
+ {
+ // Arrange
+ var sut = CreateSut(BuildExportData(new ParamSetExportData
+ {
+ ParamSetKey = "SERVICE",
+ Values = [],
+ Error = "XML-RPC fault -321 (device not reachable (e.g. sleeping battery-powered device))"
+ }));
+
+ var options = new ShowDeviceDetailsOptions { Address = DeviceAddress };
+
+ // Act
+ await sut.Command.ExecuteAsync(options);
+
+ // Assert
+ sut.Output.ToString().Should()
+ .Contain("Values could not be read: XML-RPC fault -321");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_ParamSetWithoutError_PrintsValues()
+ {
+ // Arrange
+ var sut = CreateSut(BuildExportData(new ParamSetExportData
+ {
+ ParamSetKey = "MASTER",
+ Values =
+ [
+ new ParamValueExportData { Key = "LONG_PRESS_TIME", Name = null, Value = 0.4 }
+ ]
+ }));
+
+ var options = new ShowDeviceDetailsOptions { Address = DeviceAddress };
+
+ // Act
+ await sut.Command.ExecuteAsync(options);
+
+ // Assert
+ sut.Output.ToString().Should().Contain("LONG_PRESS_TIME").And.NotContain("could not be read");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_WithParamSetsOption_ForwardsWhitelistToBuildAndExport()
+ {
+ // Arrange
+ var sut = CreateSut(BuildExportData());
+ var options = new ShowDeviceDetailsOptions
+ {
+ Address = DeviceAddress,
+ ParamSets = "master, values"
+ };
+
+ // Act
+ await sut.Command.ExecuteAsync(options);
+
+ // Assert - whitelist entries are split and trimmed, and reach both the build and the export options.
+ sut.CapturedBuildOptions.Should().NotBeNull();
+ sut.CapturedBuildOptions!.ParamSetWhitelist.Should().BeEquivalentTo("master", "values");
+ sut.CapturedExportOptions.Should().NotBeNull();
+ sut.CapturedExportOptions!.ParamSetWhitelist.Should().BeEquivalentTo("master", "values");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_WithoutParamSetsOption_WhitelistIsNull()
+ {
+ // Arrange
+ var sut = CreateSut(BuildExportData());
+ var options = new ShowDeviceDetailsOptions { Address = DeviceAddress };
+
+ // Act
+ await sut.Command.ExecuteAsync(options);
+
+ // Assert
+ sut.CapturedBuildOptions.Should().NotBeNull();
+ sut.CapturedBuildOptions!.ParamSetWhitelist.Should().BeNull();
+ sut.CapturedExportOptions.Should().NotBeNull();
+ sut.CapturedExportOptions!.ParamSetWhitelist.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_ErroredParamSetFollowedByHealthyOne_SuppressesErroredValuesAndPrintsHealthyOnes()
+ {
+ // Arrange
+ var sut = CreateSut(BuildExportData(
+ new ParamSetExportData
+ {
+ ParamSetKey = "SERVICE",
+ Values =
+ [
+ new ParamValueExportData { Key = "MUST_NOT_APPEAR", Name = null, Value = 1 }
+ ],
+ Error = "XML-RPC fault -321"
+ },
+ new ParamSetExportData
+ {
+ ParamSetKey = "MASTER",
+ Values =
+ [
+ new ParamValueExportData { Key = "LONG_PRESS_TIME", Name = null, Value = 0.4 }
+ ]
+ }));
+
+ var options = new ShowDeviceDetailsOptions { Address = DeviceAddress };
+
+ // Act
+ await sut.Command.ExecuteAsync(options);
+
+ // Assert - errored values are suppressed, but the loop continues with the next ParamSet.
+ var output = sut.Output.ToString();
+ output.Should().NotContain("MUST_NOT_APPEAR");
+ output.Should().Contain("LONG_PRESS_TIME");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_ErrorContainingMarkupCharacters_PrintsLiteralText()
+ {
+ // Arrange
+ var sut = CreateSut(BuildExportData(new ParamSetExportData
+ {
+ ParamSetKey = "SERVICE",
+ Values = [],
+ Error = "fault [brackets] included"
+ }));
+
+ var options = new ShowDeviceDetailsOptions { Address = DeviceAddress };
+
+ // Act
+ var act = async () => await sut.Command.ExecuteAsync(options);
+
+ // Assert - markup characters in the error must be escaped, not interpreted.
+ await act.Should().NotThrowAsync();
+ sut.Output.ToString().Should().Contain("fault [brackets] included");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_WhitelistMatchesNothing_PrintsFilterWarning()
+ {
+ // Arrange - whitelist set, but the export contains no ParamSets at all.
+ var sut = CreateSut(BuildExportData());
+ var options = new ShowDeviceDetailsOptions
+ {
+ Address = DeviceAddress,
+ ParamSets = "MASTRE"
+ };
+
+ // Act
+ await sut.Command.ExecuteAsync(options);
+
+ // Assert
+ sut.Output.ToString().Should().Contain("No ParamSets matched the --param-sets filter.");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_WhitelistMatchesParamSets_DoesNotPrintFilterWarning()
+ {
+ // Arrange
+ var sut = CreateSut(BuildExportData(new ParamSetExportData
+ {
+ ParamSetKey = "MASTER",
+ Values = []
+ }));
+ var options = new ShowDeviceDetailsOptions
+ {
+ Address = DeviceAddress,
+ ParamSets = "MASTER"
+ };
+
+ // Act
+ await sut.Command.ExecuteAsync(options);
+
+ // Assert
+ sut.Output.ToString().Should().NotContain("No ParamSets matched");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_NoWhitelistAndNoParamSets_DoesNotPrintFilterWarning()
+ {
+ // Arrange
+ var sut = CreateSut(BuildExportData());
+ var options = new ShowDeviceDetailsOptions { Address = DeviceAddress };
+
+ // Act
+ await sut.Command.ExecuteAsync(options);
+
+ // Assert
+ sut.Output.ToString().Should().NotContain("No ParamSets matched");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_WhitelistSetButDeviceHasNoParamSetsAtAll_DoesNotPrintFilterWarning()
+ {
+ // Arrange - the device offers no ParamSets anywhere, so the filter did not remove anything.
+ var sut = CreateSut(BuildExportData(paramSetKeys: []));
+ var options = new ShowDeviceDetailsOptions
+ {
+ Address = DeviceAddress,
+ ParamSets = "MASTER"
+ };
+
+ // Act
+ await sut.Command.ExecuteAsync(options);
+
+ // Assert
+ sut.Output.ToString().Should().NotContain("No ParamSets matched");
+ }
+
+ private static DeviceExportData BuildExportData(params ParamSetExportData[] paramSets)
+ {
+ return BuildExportData(["MASTER", "SERVICE"], paramSets);
+ }
+
+ private static DeviceExportData BuildExportData(string[] paramSetKeys,
+ params ParamSetExportData[] paramSets)
+ {
+ return new DeviceExportData
+ {
+ Name = "Wall Switch",
+ Address = DeviceAddress,
+ DeviceType = "HMIP-WRC2",
+ ParamSetKeys = paramSetKeys,
+ FirmwareVersion = "1.18.2",
+ Ccu = "OG",
+ ParamSetValues = paramSets,
+ Channels = []
+ };
+ }
+
+ private static SutContext CreateSut(DeviceExportData exportData)
+ {
+ var output = new StringWriter();
+ var console = AnsiConsole.Create(new AnsiConsoleSettings
+ {
+ Ansi = AnsiSupport.No,
+ ColorSystem = ColorSystemSupport.NoColors,
+ Interactive = InteractionSupport.No,
+ Out = new AnsiConsoleOutput(output)
+ });
+
+ var context = new SutContext(output);
+
+ var multiCcuClient = A.Fake();
+ A.CallTo(() => multiCcuClient.GetCompleteDeviceAsync(DeviceAddress, A._))
+ .ReturnsLazily((string _, CompleteCcuDeviceBuildOptions buildOptions) =>
+ {
+ context.CapturedBuildOptions = buildOptions;
+ return Task.FromResult(A.Fake());
+ });
+
+ var deviceExporter = A.Fake();
+ A.CallTo(() => deviceExporter.BuildExportData(A._, A._))
+ .ReturnsLazily((ICompleteCcuDevice _, DeviceExportOptions exportOptions) =>
+ {
+ context.CapturedExportOptions = exportOptions;
+ return exportData;
+ });
+
+ context.Command = new ShowDeviceDetailsCommand(console, multiCcuClient, deviceExporter);
+
+ return context;
+ }
+
+ private sealed class SutContext(StringWriter output)
+ {
+ public ShowDeviceDetailsCommand Command { get; set; } = null!;
+
+ public StringWriter Output { get; } = output;
+
+ public CompleteCcuDeviceBuildOptions? CapturedBuildOptions { get; set; }
+
+ public DeviceExportOptions? CapturedExportOptions { get; set; }
+ }
+}
From e5de39c9ced5d5a522965198c67b45138f0d2969 Mon Sep 17 00:00:00 2001
From: darthsharp <48331467+darthsharp@users.noreply.github.com>
Date: Sun, 12 Jul 2026 15:49:42 +0200
Subject: [PATCH 2/3] Add `SkipServiceParamSet` to param set filtering and
device export logic
- Introduced `SkipServiceParamSet` option to exclude SERVICE ParamSets during export.
- Updated `DeviceExportOptions` and CLI commands to support the new filtering logic.
- Enhanced `DeviceExporter` to respect `SkipServiceParamSet` and whitelist prioritization.
- Added corresponding tests for export workflows, CLI commands, and filtering scenarios.
---
.../CompleteCcuDeviceBuildOptions.cs | 27 +-
.../ParamSetFilter.cs | 44 ++++
.../Exporting/ChannelExportData.cs | 8 +-
.../Exporting/DeviceExportData.cs | 8 +-
.../Exporting/DeviceExportOptions.cs | 35 ++-
.../Exporting/DeviceExporter.cs | 11 +-
.../Device/Export/ExportDevicesCommand.cs | 15 +-
.../Device/Export/ExportDevicesOptions.cs | 12 +
.../ShowDetails/ShowDeviceDetailsCommand.cs | 28 +-
.../ShowDetails/ShowDeviceDetailsOptions.cs | 13 +
.../CompleteCcuDeviceBuildOptionsTests.cs | 71 +++++
.../CompleteCcuDeviceBuilderTests.cs | 93 +++++++
.../Exporting/DeviceExportOptionsTests.cs | 88 +++++++
.../Exporting/DeviceExporterTests.cs | 121 +++++++++
.../ParamSetFilterTests.cs | 95 +++++++
.../Export/ExportDevicesCommandTests.cs | 157 +++++++++++
.../ShowDeviceDetailsCommandTests.cs | 248 +++++++++++++++++-
17 files changed, 1050 insertions(+), 24 deletions(-)
create mode 100644 source/CreativeCoders.HomeMatic.Core/ParamSetFilter.cs
create mode 100644 tests/CreativeCoders.HomeMatic.Tests/ParamSetFilterTests.cs
create mode 100644 tests/CreativeCoders.HomeMatic.Tools.Cli.Commands.Tests/Device/Export/ExportDevicesCommandTests.cs
diff --git a/source/CreativeCoders.HomeMatic.Core/CompleteCcuDeviceBuildOptions.cs b/source/CreativeCoders.HomeMatic.Core/CompleteCcuDeviceBuildOptions.cs
index fb5c75b..cf71a07 100644
--- a/source/CreativeCoders.HomeMatic.Core/CompleteCcuDeviceBuildOptions.cs
+++ b/source/CreativeCoders.HomeMatic.Core/CompleteCcuDeviceBuildOptions.cs
@@ -1,4 +1,6 @@
+using System;
using System.Collections.Generic;
+using CreativeCoders.HomeMatic.Core.Parameters;
using CreativeCoders.HomeMatic.XmlRpc.Links;
using JetBrains.Annotations;
@@ -31,12 +33,29 @@ public class CompleteCcuDeviceBuildOptions
public ICollection? ParamSetWhitelist { get; set; }
///
- /// Determines whether a ParamSet key is allowed based on the .
+ /// Gets or sets a value indicating whether the ParamSet is skipped
+ /// instead of being fetched from the CCU.
///
- /// The ParamSet key to check.
- /// true if the key is allowed or no whitelist is configured; otherwise false.
+ /// to skip the SERVICE ParamSet; otherwise, .
+ /// Default is .
+ ///
+ /// Skipping takes precedence over : the SERVICE ParamSet is skipped even
+ /// when the whitelist explicitly contains it. Reading the SERVICE ParamSet frequently fails for
+ /// battery-powered devices that are not reachable, so skipping it avoids both the request and the resulting
+ /// read error.
+ ///
+ public bool SkipServiceParamSet { get; set; }
+
+ ///
+ /// Determines whether a ParamSet may be fetched from the CCU.
+ ///
+ /// The ParamSet key to check. The comparison is case-insensitive.
+ /// if the ParamSet may be fetched; otherwise, .
+ /// The SERVICE ParamSet is never allowed while is ,
+ /// even when the contains it.
+ /// is .
public bool IsParamSetAllowed(string paramSetKey)
{
- return WhitelistFilter.IsAllowed(ParamSetWhitelist, paramSetKey);
+ return ParamSetFilter.IsParamSetAllowed(ParamSetWhitelist, SkipServiceParamSet, paramSetKey);
}
}
diff --git a/source/CreativeCoders.HomeMatic.Core/ParamSetFilter.cs b/source/CreativeCoders.HomeMatic.Core/ParamSetFilter.cs
new file mode 100644
index 0000000..6d858e8
--- /dev/null
+++ b/source/CreativeCoders.HomeMatic.Core/ParamSetFilter.cs
@@ -0,0 +1,44 @@
+using System;
+using System.Collections.Generic;
+using CreativeCoders.Core;
+using CreativeCoders.HomeMatic.Core.Parameters;
+using JetBrains.Annotations;
+
+namespace CreativeCoders.HomeMatic.Core;
+
+///
+/// Provides the shared filter semantics that decide whether a ParamSet may be used.
+///
+///
+/// The filter combines two independent rules: skipping the ParamSet and an
+/// optional whitelist. Skipping takes precedence, so the SERVICE ParamSet is rejected even when the whitelist
+/// explicitly contains it.
+///
+[PublicAPI]
+public static class ParamSetFilter
+{
+ ///
+ /// Determines whether a ParamSet may be used.
+ ///
+ /// The allowed ParamSet keys, or for no whitelist filtering.
+ /// to reject the
+ /// ParamSet regardless of the whitelist; otherwise, .
+ /// The ParamSet key to check. The comparison is case-insensitive.
+ /// if the ParamSet may be used; otherwise, .
+ /// The SERVICE ParamSet is never allowed while is
+ /// , even when contains it.
+ /// is .
+ public static bool IsParamSetAllowed(ICollection? whitelist, bool skipServiceParamSet,
+ string paramSetKey)
+ {
+ Ensure.NotNull(paramSetKey);
+
+ if (skipServiceParamSet
+ && string.Equals(paramSetKey, ParamSetKey.Service, StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ return WhitelistFilter.IsAllowed(whitelist, paramSetKey);
+ }
+}
diff --git a/source/CreativeCoders.HomeMatic/Exporting/ChannelExportData.cs b/source/CreativeCoders.HomeMatic/Exporting/ChannelExportData.cs
index 649c480..b505a96 100644
--- a/source/CreativeCoders.HomeMatic/Exporting/ChannelExportData.cs
+++ b/source/CreativeCoders.HomeMatic/Exporting/ChannelExportData.cs
@@ -24,9 +24,15 @@ public class ChannelExportData
public required int Index { get; init; }
///
- /// Gets the parameter-set keys available for this channel.
+ /// Gets the exported parameter-set keys of this channel.
///
/// The array of parameter-set keys.
+ ///
+ /// The keys are filtered by the export options: a ParamSet excluded by
+ /// or by
+ /// is absent here as well. Without export options the
+ /// channel reports all of its parameter-set keys.
+ ///
public required string[] ParamSets { get; init; }
///
diff --git a/source/CreativeCoders.HomeMatic/Exporting/DeviceExportData.cs b/source/CreativeCoders.HomeMatic/Exporting/DeviceExportData.cs
index b4a8679..d9f03d8 100644
--- a/source/CreativeCoders.HomeMatic/Exporting/DeviceExportData.cs
+++ b/source/CreativeCoders.HomeMatic/Exporting/DeviceExportData.cs
@@ -24,9 +24,15 @@ public class DeviceExportData
public required string DeviceType { get; init; }
///
- /// Gets the parameter-set keys available for this device.
+ /// Gets the exported parameter-set keys of this device.
///
/// The array of parameter-set keys.
+ ///
+ /// The keys are filtered by the export options: a ParamSet excluded by
+ /// or by
+ /// is absent here as well. Without export options the
+ /// device reports all of its parameter-set keys.
+ ///
public required string[] ParamSetKeys { get; init; }
///
diff --git a/source/CreativeCoders.HomeMatic/Exporting/DeviceExportOptions.cs b/source/CreativeCoders.HomeMatic/Exporting/DeviceExportOptions.cs
index 504f030..b934cd0 100644
--- a/source/CreativeCoders.HomeMatic/Exporting/DeviceExportOptions.cs
+++ b/source/CreativeCoders.HomeMatic/Exporting/DeviceExportOptions.cs
@@ -1,4 +1,5 @@
using CreativeCoders.HomeMatic.Core;
+using CreativeCoders.HomeMatic.Core.Parameters;
using CreativeCoders.HomeMatic.XmlRpc.Links;
using JetBrains.Annotations;
@@ -11,6 +12,10 @@ public class DeviceExportOptions
/// Whitelist of ParamSet keys to include in the export (e.g. "MASTER", "VALUES").
/// If empty or null, all ParamSets are exported.
///
+ ///
+ /// The whitelist applies to the exported ParamSet values as well as to the exported ParamSet key lists, so a
+ /// filtered ParamSet is absent from the export entirely.
+ ///
public ICollection? ParamSetWhitelist { get; set; }
///
@@ -41,13 +46,29 @@ public class DeviceExportOptions
public GetLinksFlags LinksFlags { get; set; } = GetLinksFlags.None;
///
- /// Determines whether a ParamSet key is allowed based on the .
+ /// Gets or sets a value indicating whether the ParamSet is skipped.
///
- /// The ParamSet key to check.
- /// true if the key is allowed or no whitelist is configured; otherwise false.
+ /// to skip the SERVICE ParamSet; otherwise, .
+ /// Default is .
+ ///
+ /// The value is applied twice: it is forwarded to
+ /// by so the SERVICE ParamSet is not fetched from the CCU, and it excludes the
+ /// SERVICE ParamSet from the export data even when the snapshot already contains it. Skipping takes precedence
+ /// over .
+ ///
+ public bool SkipServiceParamSet { get; set; }
+
+ ///
+ /// Determines whether a ParamSet may be included in the export.
+ ///
+ /// The ParamSet key to check. The comparison is case-insensitive.
+ /// if the ParamSet may be included; otherwise, .
+ /// The SERVICE ParamSet is never allowed while is ,
+ /// even when the contains it.
+ /// is .
public bool IsParamSetAllowed(string paramSetKey)
{
- return WhitelistFilter.IsAllowed(ParamSetWhitelist, paramSetKey);
+ return ParamSetFilter.IsParamSetAllowed(ParamSetWhitelist, SkipServiceParamSet, paramSetKey);
}
///
@@ -64,14 +85,16 @@ public bool IsParamValueNameAllowed(string paramValueName)
/// Builds a matching this export configuration.
///
/// A that includes links iff is set
- /// and forwards the so filtered ParamSets are not fetched at all.
+ /// and forwards the and so filtered ParamSets
+ /// are not fetched at all.
public CompleteCcuDeviceBuildOptions ToBuildOptions()
{
return new CompleteCcuDeviceBuildOptions
{
IncludeLinks = IncludeLinks,
LinksFlags = LinksFlags,
- ParamSetWhitelist = ParamSetWhitelist
+ ParamSetWhitelist = ParamSetWhitelist,
+ SkipServiceParamSet = SkipServiceParamSet
};
}
}
diff --git a/source/CreativeCoders.HomeMatic/Exporting/DeviceExporter.cs b/source/CreativeCoders.HomeMatic/Exporting/DeviceExporter.cs
index 97d8120..0afcd55 100644
--- a/source/CreativeCoders.HomeMatic/Exporting/DeviceExporter.cs
+++ b/source/CreativeCoders.HomeMatic/Exporting/DeviceExporter.cs
@@ -38,7 +38,7 @@ public DeviceExportData BuildExportData(ICompleteCcuDevice device, DeviceExportO
Name = device.DeviceData.Name,
Address = device.DeviceData.Uri.Address,
DeviceType = device.DeviceData.DeviceType,
- ParamSetKeys = device.DeviceData.ParamSets,
+ ParamSetKeys = FilterParamSetKeys(device.DeviceData.ParamSets, options),
FirmwareVersion = device.DeviceData.Firmware,
Ccu = device.DeviceData.Uri.HostDisplayName,
ParamSetValues = BuildParamSetExportData(device.ParamSetValues, options),
@@ -55,12 +55,19 @@ private static ChannelExportData BuildChannelExportData(
Address = channel.ChannelData.Uri.Address,
DeviceType = channel.ChannelData.DeviceType,
Index = channel.ChannelData.Index,
- ParamSets = channel.ChannelData.ParamSets,
+ ParamSets = FilterParamSetKeys(channel.ChannelData.ParamSets, options),
ParamSetValues = BuildParamSetExportData(channel.ParamSetValues, options),
Links = options?.IncludeLinks == true ? BuildLinkExportData(channel.Links) : null
};
}
+ private static string[] FilterParamSetKeys(string[] paramSetKeys, DeviceExportOptions? options)
+ {
+ return options is null
+ ? paramSetKeys
+ : paramSetKeys.Where(options.IsParamSetAllowed).ToArray();
+ }
+
private static IEnumerable BuildLinkExportData(IEnumerable links)
{
return links
diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/Export/ExportDevicesCommand.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/Export/ExportDevicesCommand.cs
index 3fda6e1..b9f5415 100644
--- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/Export/ExportDevicesCommand.cs
+++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/Export/ExportDevicesCommand.cs
@@ -16,17 +16,22 @@ public class ExportDevicesCommand(IAnsiConsole console, IMultiCcuClient multiCcu
{
private readonly IDeviceExporter _deviceExporter = Ensure.NotNull(deviceExporter);
+ private DeviceExportOptions _exportOptions = new() { WriteIndented = true };
+
protected override object TransformData(ICompleteCcuDevice device)
{
- return _deviceExporter.BuildExportData(device, new DeviceExportOptions
- {
- WriteIndented = true
- });
+ return _deviceExporter.BuildExportData(device, _exportOptions);
}
protected override Task LoadDataAsync(IMultiCcuClient ccuClient, ExportDevicesOptions options)
{
- return ccuClient.GetCompleteDeviceAsync(options.Address);
+ _exportOptions = new DeviceExportOptions
+ {
+ WriteIndented = true,
+ SkipServiceParamSet = options.SkipServiceParamSet
+ };
+
+ return ccuClient.GetCompleteDeviceAsync(options.Address, _exportOptions.ToBuildOptions());
}
protected override string GetOutputFileName(ExportDevicesOptions options)
diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/Export/ExportDevicesOptions.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/Export/ExportDevicesOptions.cs
index 3e3a17e..c6f92e5 100644
--- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/Export/ExportDevicesOptions.cs
+++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/Export/ExportDevicesOptions.cs
@@ -11,4 +11,16 @@ public class ExportDevicesOptions
[OptionParameter('o', "output", HelpText = "Output file")]
public string OutputFileName { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets a value indicating whether the SERVICE ParamSet is skipped.
+ ///
+ /// to skip the SERVICE ParamSet; otherwise, .
+ /// Default is .
+ ///
+ /// If set, the SERVICE ParamSet is not loaded from the CCU and is not written to the export file.
+ ///
+ [OptionParameter("skip-service-params",
+ HelpText = "Skip the SERVICE ParamSet. It is not loaded from the CCU and is not written to the export file")]
+ public bool SkipServiceParamSet { get; set; }
}
diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/ShowDetails/ShowDeviceDetailsCommand.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/ShowDetails/ShowDeviceDetailsCommand.cs
index 260eb42..82247ad 100644
--- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/ShowDetails/ShowDeviceDetailsCommand.cs
+++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/ShowDetails/ShowDeviceDetailsCommand.cs
@@ -1,6 +1,7 @@
using CreativeCoders.Cli.Core;
using CreativeCoders.Core;
using CreativeCoders.HomeMatic.Core;
+using CreativeCoders.HomeMatic.Core.Parameters;
using CreativeCoders.HomeMatic.Exporting;
using CreativeCoders.SysConsole.Core;
using JetBrains.Annotations;
@@ -27,6 +28,7 @@ public async Task ExecuteAsync(ShowDeviceDetailsOptions options)
var exportOptions = new DeviceExportOptions
{
IncludeLinks = true,
+ SkipServiceParamSet = options.SkipServiceParamSet,
ParamSetWhitelist = string.IsNullOrWhiteSpace(options.ParamSets)
? null
: options.ParamSets.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
@@ -41,10 +43,32 @@ public async Task ExecuteAsync(ShowDeviceDetailsOptions options)
_console.WriteLine($"Show device details for '{options.Address}'");
_console.WriteLine();
- if (exportOptions.ParamSetWhitelist is { Count: > 0 }
+ var serviceIsOverridden = exportOptions.SkipServiceParamSet
+ && exportOptions.ParamSetWhitelist is not null
+ && exportOptions.ParamSetWhitelist.Contains(ParamSetKey.Service,
+ StringComparer.OrdinalIgnoreCase);
+
+ if (serviceIsOverridden)
+ {
+ _console.MarkupLine("[yellow]--skip-service-params overrides SERVICE in --param-sets.[/]");
+ _console.WriteLine();
+ }
+
+ // The export data no longer proves that the device offers ParamSets at all, because its key lists are
+ // filtered as well. The unfiltered snapshot does.
+ var deviceOffersParamSets = device.DeviceData.ParamSets.Length > 0
+ || device.Channels.Any(x => x.ChannelData.ParamSets.Length > 0);
+
+ // Does any whitelist entry still stand a chance after the skip removed SERVICE? If none does, the filter
+ // did not fail to match - the skip emptied it, and the warning above already says so. Only a surviving
+ // entry that matched nothing justifies blaming the --param-sets filter.
+ var whitelistHasSurvivingEntry =
+ exportOptions.ParamSetWhitelist?.Any(exportOptions.IsParamSetAllowed) == true;
+
+ if (whitelistHasSurvivingEntry
&& !exportData.ParamSetValues.Any()
&& exportData.Channels.All(x => !x.ParamSetValues.Any())
- && (exportData.ParamSetKeys.Length > 0 || exportData.Channels.Any(x => x.ParamSets.Any())))
+ && deviceOffersParamSets)
{
_console.MarkupLine("[yellow]No ParamSets matched the --param-sets filter.[/]");
_console.WriteLine();
diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/ShowDetails/ShowDeviceDetailsOptions.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/ShowDetails/ShowDeviceDetailsOptions.cs
index 4315708..4915abf 100644
--- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/ShowDetails/ShowDeviceDetailsOptions.cs
+++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/ShowDetails/ShowDeviceDetailsOptions.cs
@@ -13,4 +13,17 @@ public class ShowDeviceDetailsOptions : CliCommandOptionsBase
[OptionParameter('p', "param-sets",
HelpText = "Comma-separated list of ParamSet keys to show (e.g. MASTER,VALUES). If omitted, all ParamSets are shown")]
public string ParamSets { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets a value indicating whether the SERVICE ParamSet is skipped.
+ ///
+ /// to skip the SERVICE ParamSet; otherwise, .
+ /// Default is .
+ ///
+ /// If set, the SERVICE ParamSet is not loaded from the CCU and is not shown. Skipping takes precedence over
+ /// , so a SERVICE entry in the ParamSet whitelist is overridden.
+ ///
+ [OptionParameter("skip-service-params",
+ HelpText = "Skip the SERVICE ParamSet. It is not loaded from the CCU and overrides a SERVICE entry in --param-sets")]
+ public bool SkipServiceParamSet { get; set; }
}
diff --git a/tests/CreativeCoders.HomeMatic.Tests/CompleteCcuDeviceBuildOptionsTests.cs b/tests/CreativeCoders.HomeMatic.Tests/CompleteCcuDeviceBuildOptionsTests.cs
index 71e47b4..5a28240 100644
--- a/tests/CreativeCoders.HomeMatic.Tests/CompleteCcuDeviceBuildOptionsTests.cs
+++ b/tests/CreativeCoders.HomeMatic.Tests/CompleteCcuDeviceBuildOptionsTests.cs
@@ -69,4 +69,75 @@ public void IsParamSetAllowed_WhitelistConfigured_ReturnsExpected(string paramSe
// Assert
result.Should().Be(expected);
}
+
+ [Fact]
+ public void SkipServiceParamSet_NotConfigured_IsFalse()
+ {
+ // Arrange
+ var options = new CompleteCcuDeviceBuildOptions();
+
+ // Act
+ var result = options.SkipServiceParamSet;
+
+ // Assert
+ result.Should().BeFalse();
+ }
+
+ [Theory]
+ [InlineData("SERVICE", false)]
+ [InlineData("service", false)]
+ [InlineData("MASTER", true)]
+ [InlineData("VALUES", true)]
+ public void IsParamSetAllowed_SkipServiceParamSetWithoutWhitelist_ReturnsExpected(string paramSetKey,
+ bool expected)
+ {
+ // Arrange
+ var options = new CompleteCcuDeviceBuildOptions
+ {
+ SkipServiceParamSet = true
+ };
+
+ // Act
+ var result = options.IsParamSetAllowed(paramSetKey);
+
+ // Assert
+ result.Should().Be(expected);
+ }
+
+ [Theory]
+ [InlineData("SERVICE", false)]
+ [InlineData("MASTER", true)]
+ [InlineData("VALUES", false)]
+ public void IsParamSetAllowed_SkipServiceParamSetAndWhitelistContainsService_SkipWins(string paramSetKey,
+ bool expected)
+ {
+ // Arrange
+ var options = new CompleteCcuDeviceBuildOptions
+ {
+ SkipServiceParamSet = true,
+ ParamSetWhitelist = ["SERVICE", "MASTER"]
+ };
+
+ // Act
+ var result = options.IsParamSetAllowed(paramSetKey);
+
+ // Assert
+ result.Should().Be(expected);
+ }
+
+ [Fact]
+ public void IsParamSetAllowed_SkipServiceParamSetIsFalse_ServiceIsAllowed()
+ {
+ // Arrange
+ var options = new CompleteCcuDeviceBuildOptions
+ {
+ SkipServiceParamSet = false
+ };
+
+ // Act
+ var result = options.IsParamSetAllowed("SERVICE");
+
+ // Assert
+ result.Should().BeTrue();
+ }
}
diff --git a/tests/CreativeCoders.HomeMatic.Tests/CompleteCcuDeviceBuilderTests.cs b/tests/CreativeCoders.HomeMatic.Tests/CompleteCcuDeviceBuilderTests.cs
index 75d63d0..429fab7 100644
--- a/tests/CreativeCoders.HomeMatic.Tests/CompleteCcuDeviceBuilderTests.cs
+++ b/tests/CreativeCoders.HomeMatic.Tests/CompleteCcuDeviceBuilderTests.cs
@@ -382,6 +382,99 @@ public async Task BuildAsync_WithParamSetWhitelist_FetchesOnlyWhitelistedParamSe
.Should().BeEquivalentTo("MASTER", "VALUES");
}
+ [Fact]
+ public async Task BuildAsync_WithSkipServiceParamSet_DoesNotFetchServiceParamSet()
+ {
+ // Arrange
+ var device = A.Fake();
+ var channel = A.Fake();
+
+ A.CallTo(() => device.Channels).Returns([channel]);
+ A.CallTo(() => device.ParamSets).Returns(["MASTER", "SERVICE"]);
+ A.CallTo(() => channel.ParamSets).Returns(["MASTER", "VALUES", "SERVICE"]);
+
+ SetupParamSet(device, "MASTER", "A", 1);
+ SetupParamSet(channel, "MASTER", "B", 2);
+ SetupParamSet(channel, "VALUES", "C", 3);
+
+ var builder = new CompleteCcuDeviceBuilder();
+ var options = new CompleteCcuDeviceBuildOptions
+ {
+ SkipServiceParamSet = true
+ };
+
+ // Act
+ var completeDevice = await builder.BuildAsync(device, options);
+
+ // Assert - SERVICE must not be requested at all, neither on the device nor on the channel.
+ A.CallTo(() => device.GetParamSetValuesAsync("SERVICE")).MustNotHaveHappened();
+ A.CallTo(() => device.GetParamSetDescriptionsAsync("SERVICE")).MustNotHaveHappened();
+ A.CallTo(() => channel.GetParamSetValuesAsync("SERVICE")).MustNotHaveHappened();
+ A.CallTo(() => channel.GetParamSetDescriptionsAsync("SERVICE")).MustNotHaveHappened();
+
+ completeDevice.ParamSetValues.Select(x => x.ParamSetKey)
+ .Should().BeEquivalentTo("MASTER");
+ completeDevice.Channels.Single().ParamSetValues.Select(x => x.ParamSetKey)
+ .Should().BeEquivalentTo("MASTER", "VALUES");
+ }
+
+ [Fact]
+ public async Task BuildAsync_WithSkipServiceParamSetAndWhitelistContainingService_DoesNotFetchServiceParamSet()
+ {
+ // Arrange
+ var device = A.Fake();
+
+ A.CallTo(() => device.Channels).Returns([]);
+ A.CallTo(() => device.ParamSets).Returns(["MASTER", "SERVICE"]);
+
+ SetupParamSet(device, "MASTER", "A", 1);
+
+ var builder = new CompleteCcuDeviceBuilder();
+ var options = new CompleteCcuDeviceBuildOptions
+ {
+ SkipServiceParamSet = true,
+ ParamSetWhitelist = ["MASTER", "SERVICE"]
+ };
+
+ // Act
+ var completeDevice = await builder.BuildAsync(device, options);
+
+ // Assert - skipping wins over an explicit whitelist entry.
+ A.CallTo(() => device.GetParamSetValuesAsync("SERVICE")).MustNotHaveHappened();
+ A.CallTo(() => device.GetParamSetDescriptionsAsync("SERVICE")).MustNotHaveHappened();
+
+ completeDevice.ParamSetValues.Select(x => x.ParamSetKey)
+ .Should().BeEquivalentTo("MASTER");
+ }
+
+ [Fact]
+ public async Task BuildAsync_WithoutSkipServiceParamSet_FetchesServiceParamSet()
+ {
+ // Arrange
+ var device = A.Fake();
+
+ A.CallTo(() => device.Channels).Returns([]);
+ A.CallTo(() => device.ParamSets).Returns(["MASTER", "SERVICE"]);
+
+ SetupParamSet(device, "MASTER", "A", 1);
+ SetupParamSet(device, "SERVICE", "ERROR_CODE", 0);
+
+ var builder = new CompleteCcuDeviceBuilder();
+ var options = new CompleteCcuDeviceBuildOptions
+ {
+ SkipServiceParamSet = false
+ };
+
+ // Act
+ var completeDevice = await builder.BuildAsync(device, options);
+
+ // Assert
+ A.CallTo(() => device.GetParamSetValuesAsync("SERVICE")).MustHaveHappenedOnceExactly();
+
+ completeDevice.ParamSetValues.Select(x => x.ParamSetKey)
+ .Should().BeEquivalentTo("MASTER", "SERVICE");
+ }
+
[Fact]
public async Task BuildAsync_WhenParamSetReadThrowsNonFaultException_PropagatesException()
{
diff --git a/tests/CreativeCoders.HomeMatic.Tests/Exporting/DeviceExportOptionsTests.cs b/tests/CreativeCoders.HomeMatic.Tests/Exporting/DeviceExportOptionsTests.cs
index 5181c28..0d31a8c 100644
--- a/tests/CreativeCoders.HomeMatic.Tests/Exporting/DeviceExportOptionsTests.cs
+++ b/tests/CreativeCoders.HomeMatic.Tests/Exporting/DeviceExportOptionsTests.cs
@@ -92,4 +92,92 @@ public void ParamValueNameWhitelist_DefaultValue_IsNull()
// Assert
options.ParamValueNameWhitelist.Should().BeNull();
}
+
+ [Fact]
+ public void SkipServiceParamSet_DefaultValue_IsFalse()
+ {
+ // Arrange & Act
+ var options = new DeviceExportOptions();
+
+ // Assert
+ options.SkipServiceParamSet.Should().BeFalse();
+ }
+
+ [Theory]
+ [InlineData("SERVICE", false)]
+ [InlineData("service", false)]
+ [InlineData("MASTER", true)]
+ [InlineData("VALUES", true)]
+ public void IsParamSetAllowed_SkipServiceParamSetWithoutWhitelist_ReturnsExpected(string paramSetKey,
+ bool expected)
+ {
+ // Arrange
+ var options = new DeviceExportOptions { SkipServiceParamSet = true };
+
+ // Act
+ var result = options.IsParamSetAllowed(paramSetKey);
+
+ // Assert
+ result.Should().Be(expected);
+ }
+
+ [Theory]
+ [InlineData("SERVICE", false)]
+ [InlineData("MASTER", true)]
+ [InlineData("VALUES", false)]
+ public void IsParamSetAllowed_SkipServiceParamSetAndWhitelistContainsService_SkipWins(string paramSetKey,
+ bool expected)
+ {
+ // Arrange
+ var options = new DeviceExportOptions
+ {
+ SkipServiceParamSet = true,
+ ParamSetWhitelist = ["SERVICE", "MASTER"]
+ };
+
+ // Act
+ var result = options.IsParamSetAllowed(paramSetKey);
+
+ // Assert
+ result.Should().Be(expected);
+ }
+
+ [Fact]
+ public void IsParamSetAllowed_SkipServiceParamSetIsFalse_ServiceIsAllowed()
+ {
+ // Arrange
+ var options = new DeviceExportOptions { SkipServiceParamSet = false };
+
+ // Act
+ var result = options.IsParamSetAllowed("SERVICE");
+
+ // Assert
+ result.Should().BeTrue();
+ }
+
+ [Fact]
+ public void ToBuildOptions_SkipServiceParamSetIsSet_IsForwardedToBuildOptions()
+ {
+ // Arrange
+ var options = new DeviceExportOptions { SkipServiceParamSet = true };
+
+ // Act
+ var buildOptions = options.ToBuildOptions();
+
+ // Assert
+ buildOptions.SkipServiceParamSet.Should().BeTrue();
+ }
+
+ [Fact]
+ public void ToBuildOptions_SkipServiceParamSetIsNotSet_IsNotForwardedToBuildOptions()
+ {
+ // Arrange
+ var options = new DeviceExportOptions();
+
+ // Act
+ var buildOptions = options.ToBuildOptions();
+
+ // Assert
+ buildOptions.SkipServiceParamSet.Should().BeFalse();
+ }
}
diff --git a/tests/CreativeCoders.HomeMatic.Tests/Exporting/DeviceExporterTests.cs b/tests/CreativeCoders.HomeMatic.Tests/Exporting/DeviceExporterTests.cs
index d482608..9153141 100644
--- a/tests/CreativeCoders.HomeMatic.Tests/Exporting/DeviceExporterTests.cs
+++ b/tests/CreativeCoders.HomeMatic.Tests/Exporting/DeviceExporterTests.cs
@@ -952,6 +952,127 @@ public void BuildExportData_WithChannelReadError_MapsErrorToChannelParamSetExpor
channelParamSet.Values.Should().BeEmpty();
}
+ [Fact]
+ public void BuildExportData_WithSkipServiceParamSet_ExcludesServiceParamSetFromDeviceAndChannels()
+ {
+ // Arrange
+ var device = new CompleteCcuDeviceFakeBuilder()
+ .WithParamSet("SERVICE", p => p.Add("ERROR_CODE", 0, descriptionId: "ERROR_CODE"))
+ .WithParamSet("MASTER", p => p.Add("AES_ACTIVE", true, descriptionId: "AES_ACTIVE"))
+ .WithChannel(c => c
+ .WithParamSet("SERVICE", p => p.Add("UNREACH", false, descriptionId: "UNREACH"))
+ .WithParamSet("VALUES", p => p.Add("STATE", true, descriptionId: "STATE")))
+ .Build();
+ var sut = new DeviceExporter();
+ var options = new DeviceExportOptions { SkipServiceParamSet = true };
+
+ // Act
+ var result = sut.BuildExportData(device, options);
+
+ // Assert - the snapshot contains SERVICE, but the export must not emit it.
+ result.ParamSetValues.Select(x => x.ParamSetKey).Should().BeEquivalentTo("MASTER");
+ result.Channels.Single().ParamSetValues.Select(x => x.ParamSetKey).Should().BeEquivalentTo("VALUES");
+ }
+
+ [Fact]
+ public void BuildExportData_WithSkipServiceParamSetAndWhitelistContainingService_ExcludesServiceParamSet()
+ {
+ // Arrange
+ var device = new CompleteCcuDeviceFakeBuilder()
+ .WithParamSet("SERVICE", p => p.Add("ERROR_CODE", 0, descriptionId: "ERROR_CODE"))
+ .WithParamSet("MASTER", p => p.Add("AES_ACTIVE", true, descriptionId: "AES_ACTIVE"))
+ .Build();
+ var sut = new DeviceExporter();
+ var options = new DeviceExportOptions
+ {
+ SkipServiceParamSet = true,
+ ParamSetWhitelist = ["SERVICE", "MASTER"]
+ };
+
+ // Act
+ var result = sut.BuildExportData(device, options);
+
+ // Assert - skipping wins over an explicit whitelist entry.
+ result.ParamSetValues.Select(x => x.ParamSetKey).Should().BeEquivalentTo("MASTER");
+ }
+
+ [Fact]
+ public void BuildExportData_WithoutSkipServiceParamSet_EmitsServiceParamSet()
+ {
+ // Arrange
+ var device = new CompleteCcuDeviceFakeBuilder()
+ .WithParamSet("SERVICE", p => p.Add("ERROR_CODE", 0, descriptionId: "ERROR_CODE"))
+ .WithParamSet("MASTER", p => p.Add("AES_ACTIVE", true, descriptionId: "AES_ACTIVE"))
+ .Build();
+ var sut = new DeviceExporter();
+ var options = new DeviceExportOptions();
+
+ // Act
+ var result = sut.BuildExportData(device, options);
+
+ // Assert
+ result.ParamSetValues.Select(x => x.ParamSetKey).Should().BeEquivalentTo("MASTER", "SERVICE");
+ }
+
+ [Fact]
+ public void BuildExportData_WithSkipServiceParamSet_RemovesServiceFromParamSetKeyLists()
+ {
+ // Arrange
+ var device = new CompleteCcuDeviceFakeBuilder()
+ .WithParamSetKeys("MASTER", "SERVICE")
+ .WithParamSet("MASTER", p => p.Add("AES_ACTIVE", true, descriptionId: "AES_ACTIVE"))
+ .WithChannel(c => c
+ .WithParamSets("VALUES", "SERVICE")
+ .WithParamSet("VALUES", p => p.Add("STATE", true, descriptionId: "STATE")))
+ .Build();
+ var sut = new DeviceExporter();
+ var options = new DeviceExportOptions { SkipServiceParamSet = true };
+
+ // Act
+ var result = sut.BuildExportData(device, options);
+
+ // Assert - SERVICE must disappear from the key lists of the device and of the channel.
+ result.ParamSetKeys.Should().BeEquivalentTo("MASTER");
+ result.Channels.Single().ParamSets.Should().BeEquivalentTo("VALUES");
+ }
+
+ [Fact]
+ public void BuildExportData_WithParamSetWhitelist_RemovesFilteredKeysFromParamSetKeyLists()
+ {
+ // Arrange
+ var device = new CompleteCcuDeviceFakeBuilder()
+ .WithParamSetKeys("MASTER", "SERVICE")
+ .WithChannel(c => c.WithParamSets("MASTER", "VALUES", "SERVICE"))
+ .Build();
+ var sut = new DeviceExporter();
+ var options = new DeviceExportOptions { ParamSetWhitelist = ["MASTER"] };
+
+ // Act
+ var result = sut.BuildExportData(device, options);
+
+ // Assert
+ result.ParamSetKeys.Should().BeEquivalentTo("MASTER");
+ result.Channels.Single().ParamSets.Should().BeEquivalentTo("MASTER");
+ }
+
+ [Fact]
+ public void BuildExportData_WithoutOptions_KeepsAllParamSetKeys()
+ {
+ // Arrange
+ var device = new CompleteCcuDeviceFakeBuilder()
+ .WithParamSetKeys("MASTER", "SERVICE")
+ .WithChannel(c => c.WithParamSets("VALUES", "SERVICE"))
+ .Build();
+ var sut = new DeviceExporter();
+
+ // Act
+ var result = sut.BuildExportData(device);
+
+ // Assert
+ result.ParamSetKeys.Should().BeEquivalentTo("MASTER", "SERVICE");
+ result.Channels.Single().ParamSets.Should().BeEquivalentTo("VALUES", "SERVICE");
+ }
+
[Fact]
public async Task ExportDeviceAsync_WithoutReadError_OmitsErrorPropertyFromJson()
{
diff --git a/tests/CreativeCoders.HomeMatic.Tests/ParamSetFilterTests.cs b/tests/CreativeCoders.HomeMatic.Tests/ParamSetFilterTests.cs
new file mode 100644
index 0000000..65718ee
--- /dev/null
+++ b/tests/CreativeCoders.HomeMatic.Tests/ParamSetFilterTests.cs
@@ -0,0 +1,95 @@
+using AwesomeAssertions;
+using CreativeCoders.HomeMatic.Core;
+
+namespace CreativeCoders.HomeMatic.Tests;
+
+public class ParamSetFilterTests
+{
+ [Theory]
+ [InlineData("SERVICE", true)]
+ [InlineData("MASTER", true)]
+ [InlineData("VALUES", true)]
+ public void IsParamSetAllowed_NoWhitelistAndNoSkip_ReturnsTrue(string paramSetKey, bool expected)
+ {
+ // Act
+ var result = ParamSetFilter.IsParamSetAllowed(null, false, paramSetKey);
+
+ // Assert
+ result.Should().Be(expected);
+ }
+
+ [Theory]
+ [InlineData("SERVICE", false)]
+ [InlineData("service", false)]
+ [InlineData("Service", false)]
+ [InlineData("MASTER", true)]
+ [InlineData("VALUES", true)]
+ public void IsParamSetAllowed_SkipServiceParamSetWithoutWhitelist_ReturnsExpected(string paramSetKey,
+ bool expected)
+ {
+ // Act
+ var result = ParamSetFilter.IsParamSetAllowed(null, true, paramSetKey);
+
+ // Assert
+ result.Should().Be(expected);
+ }
+
+ [Theory]
+ [InlineData("SERVICE", false)]
+ [InlineData("MASTER", true)]
+ [InlineData("VALUES", false)]
+ public void IsParamSetAllowed_SkipServiceParamSetAndWhitelistContainsService_SkipWins(string paramSetKey,
+ bool expected)
+ {
+ // Act
+ var result = ParamSetFilter.IsParamSetAllowed(["SERVICE", "MASTER"], true, paramSetKey);
+
+ // Assert
+ result.Should().Be(expected);
+ }
+
+ [Fact]
+ public void IsParamSetAllowed_WhitelistContainsServiceAndNoSkip_ServiceIsAllowed()
+ {
+ // Act
+ var result = ParamSetFilter.IsParamSetAllowed(["SERVICE"], false, "SERVICE");
+
+ // Assert
+ result.Should().BeTrue();
+ }
+
+ [Theory]
+ [InlineData("MASTER", true)]
+ [InlineData("master", true)]
+ [InlineData("VALUES", false)]
+ public void IsParamSetAllowed_WhitelistWithoutSkip_DelegatesToWhitelistFilter(string paramSetKey, bool expected)
+ {
+ // Act
+ var result = ParamSetFilter.IsParamSetAllowed(["MASTER"], false, paramSetKey);
+
+ // Assert
+ result.Should().Be(expected);
+ }
+
+ [Fact]
+ public void IsParamSetAllowed_ParamSetKeyIsNull_ThrowsArgumentNullException()
+ {
+ // Act
+ var act = () => ParamSetFilter.IsParamSetAllowed(null, false, null!);
+
+ // Assert
+ act.Should().Throw().WithParameterName("paramSetKey");
+ }
+
+ [Fact]
+ public void IsParamSetAllowed_EmptyWhitelist_AllowsEverythingExceptSkippedService()
+ {
+ // Act
+ var serviceAllowed = ParamSetFilter.IsParamSetAllowed([], true, "SERVICE");
+ var masterAllowed = ParamSetFilter.IsParamSetAllowed([], true, "MASTER");
+
+ // Assert
+ serviceAllowed.Should().BeFalse();
+ masterAllowed.Should().BeTrue();
+ }
+}
diff --git a/tests/CreativeCoders.HomeMatic.Tools.Cli.Commands.Tests/Device/Export/ExportDevicesCommandTests.cs b/tests/CreativeCoders.HomeMatic.Tools.Cli.Commands.Tests/Device/Export/ExportDevicesCommandTests.cs
new file mode 100644
index 0000000..e068a1e
--- /dev/null
+++ b/tests/CreativeCoders.HomeMatic.Tools.Cli.Commands.Tests/Device/Export/ExportDevicesCommandTests.cs
@@ -0,0 +1,157 @@
+using AwesomeAssertions;
+using CreativeCoders.HomeMatic.Core;
+using CreativeCoders.HomeMatic.Core.Devices;
+using CreativeCoders.HomeMatic.Exporting;
+using CreativeCoders.HomeMatic.Tools.Cli.Commands.Device.Export;
+using FakeItEasy;
+using Spectre.Console;
+
+namespace CreativeCoders.HomeMatic.Tools.Cli.Commands.Tests.Device.Export;
+
+public class ExportDevicesCommandTests : IDisposable
+{
+ private const string DeviceAddress = "00019F2999BE83";
+
+ private readonly string _outputDirectory =
+ Path.Combine(Path.GetTempPath(), $"export-devices-tests-{Guid.NewGuid():N}");
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_outputDirectory))
+ {
+ Directory.Delete(_outputDirectory, true);
+ }
+
+ GC.SuppressFinalize(this);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_SkipServiceParamSetSet_ForwardsSkipToBuildAndExportOptions()
+ {
+ // Arrange
+ var sut = CreateSut();
+ var options = new ExportDevicesOptions
+ {
+ Address = DeviceAddress,
+ OutputFileName = CreateOutputFileName(),
+ SkipServiceParamSet = true
+ };
+
+ // Act
+ await sut.Command.ExecuteAsync(options);
+
+ // Assert - the flag must reach the CCU fetch and the export data building.
+ sut.CapturedBuildOptions.Should().NotBeNull();
+ sut.CapturedBuildOptions!.SkipServiceParamSet.Should().BeTrue();
+ sut.CapturedExportOptions.Should().NotBeNull();
+ sut.CapturedExportOptions!.SkipServiceParamSet.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_SkipServiceParamSetNotSet_BuildAndExportOptionsDoNotSkipService()
+ {
+ // Arrange
+ var sut = CreateSut();
+ var options = new ExportDevicesOptions
+ {
+ Address = DeviceAddress,
+ OutputFileName = CreateOutputFileName()
+ };
+
+ // Act
+ await sut.Command.ExecuteAsync(options);
+
+ // Assert
+ sut.CapturedBuildOptions.Should().NotBeNull();
+ sut.CapturedBuildOptions!.SkipServiceParamSet.Should().BeFalse();
+ sut.CapturedExportOptions.Should().NotBeNull();
+ sut.CapturedExportOptions!.SkipServiceParamSet.Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_WithAnyOptions_ExportOptionsWriteIndentedIsSet()
+ {
+ // Arrange
+ var sut = CreateSut();
+ var options = new ExportDevicesOptions
+ {
+ Address = DeviceAddress,
+ OutputFileName = CreateOutputFileName(),
+ SkipServiceParamSet = true
+ };
+
+ // Act
+ await sut.Command.ExecuteAsync(options);
+
+ // Assert
+ sut.CapturedExportOptions.Should().NotBeNull();
+ sut.CapturedExportOptions!.WriteIndented.Should().BeTrue();
+ }
+
+ private string CreateOutputFileName()
+ {
+ Directory.CreateDirectory(_outputDirectory);
+
+ return Path.Combine(_outputDirectory, "device.json");
+ }
+
+ private static SutContext CreateSut()
+ {
+ var output = new StringWriter();
+ var console = AnsiConsole.Create(new AnsiConsoleSettings
+ {
+ Ansi = AnsiSupport.No,
+ ColorSystem = ColorSystemSupport.NoColors,
+ Interactive = InteractionSupport.No,
+ Out = new AnsiConsoleOutput(output)
+ });
+
+ var context = new SutContext(output);
+
+ var multiCcuClient = A.Fake();
+ A.CallTo(() => multiCcuClient.GetCompleteDeviceAsync(DeviceAddress, A._))
+ .ReturnsLazily((string _, CompleteCcuDeviceBuildOptions buildOptions) =>
+ {
+ context.CapturedBuildOptions = buildOptions;
+ return Task.FromResult(A.Fake());
+ });
+
+ var deviceExporter = A.Fake();
+ A.CallTo(() => deviceExporter.BuildExportData(A._, A._))
+ .ReturnsLazily((ICompleteCcuDevice _, DeviceExportOptions exportOptions) =>
+ {
+ context.CapturedExportOptions = exportOptions;
+ return CreateExportData();
+ });
+
+ context.Command = new ExportDevicesCommand(console, multiCcuClient, deviceExporter);
+
+ return context;
+ }
+
+ private static DeviceExportData CreateExportData()
+ {
+ return new DeviceExportData
+ {
+ Name = "Wall Switch",
+ Address = DeviceAddress,
+ DeviceType = "HMIP-WRC2",
+ ParamSetKeys = ["MASTER"],
+ FirmwareVersion = "1.18.2",
+ Ccu = "OG",
+ ParamSetValues = [],
+ Channels = []
+ };
+ }
+
+ private sealed class SutContext(StringWriter output)
+ {
+ public ExportDevicesCommand Command { get; set; } = null!;
+
+ public StringWriter Output { get; } = output;
+
+ public CompleteCcuDeviceBuildOptions? CapturedBuildOptions { get; set; }
+
+ public DeviceExportOptions? CapturedExportOptions { get; set; }
+ }
+}
diff --git a/tests/CreativeCoders.HomeMatic.Tools.Cli.Commands.Tests/Device/ShowDetails/ShowDeviceDetailsCommandTests.cs b/tests/CreativeCoders.HomeMatic.Tools.Cli.Commands.Tests/Device/ShowDetails/ShowDeviceDetailsCommandTests.cs
index d7f768c..5d0d8e9 100644
--- a/tests/CreativeCoders.HomeMatic.Tools.Cli.Commands.Tests/Device/ShowDetails/ShowDeviceDetailsCommandTests.cs
+++ b/tests/CreativeCoders.HomeMatic.Tools.Cli.Commands.Tests/Device/ShowDetails/ShowDeviceDetailsCommandTests.cs
@@ -206,7 +206,7 @@ public async Task ExecuteAsync_NoWhitelistAndNoParamSets_DoesNotPrintFilterWarni
public async Task ExecuteAsync_WhitelistSetButDeviceHasNoParamSetsAtAll_DoesNotPrintFilterWarning()
{
// Arrange - the device offers no ParamSets anywhere, so the filter did not remove anything.
- var sut = CreateSut(BuildExportData(paramSetKeys: []));
+ var sut = CreateSut(BuildExportData(paramSetKeys: []), snapshotParamSets: []);
var options = new ShowDeviceDetailsOptions
{
Address = DeviceAddress,
@@ -220,6 +220,217 @@ public async Task ExecuteAsync_WhitelistSetButDeviceHasNoParamSetsAtAll_DoesNotP
sut.Output.ToString().Should().NotContain("No ParamSets matched");
}
+ [Fact]
+ public async Task ExecuteAsync_WithSkipServiceParamsOption_ForwardsFlagToBuildAndExport()
+ {
+ // Arrange
+ var sut = CreateSut(BuildExportData());
+ var options = new ShowDeviceDetailsOptions
+ {
+ Address = DeviceAddress,
+ SkipServiceParamSet = true
+ };
+
+ // Act
+ await sut.Command.ExecuteAsync(options);
+
+ // Assert - the flag must reach the fetch (build options) and the export options.
+ sut.CapturedBuildOptions.Should().NotBeNull();
+ sut.CapturedBuildOptions!.SkipServiceParamSet.Should().BeTrue();
+ sut.CapturedExportOptions.Should().NotBeNull();
+ sut.CapturedExportOptions!.SkipServiceParamSet.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_WithoutSkipServiceParamsOption_FlagIsFalse()
+ {
+ // Arrange
+ var sut = CreateSut(BuildExportData());
+ var options = new ShowDeviceDetailsOptions { Address = DeviceAddress };
+
+ // Act
+ await sut.Command.ExecuteAsync(options);
+
+ // Assert
+ sut.CapturedBuildOptions.Should().NotBeNull();
+ sut.CapturedBuildOptions!.SkipServiceParamSet.Should().BeFalse();
+ sut.CapturedExportOptions.Should().NotBeNull();
+ sut.CapturedExportOptions!.SkipServiceParamSet.Should().BeFalse();
+ }
+
+ [Theory]
+ [InlineData("SERVICE")]
+ [InlineData("service")]
+ [InlineData("MASTER,SERVICE")]
+ public async Task ExecuteAsync_SkipServiceParamsAndServiceInWhitelist_PrintsOverrideWarning(string paramSets)
+ {
+ // Arrange
+ var sut = CreateSut(BuildExportData(new ParamSetExportData
+ {
+ ParamSetKey = "MASTER",
+ Values = []
+ }));
+ var options = new ShowDeviceDetailsOptions
+ {
+ Address = DeviceAddress,
+ ParamSets = paramSets,
+ SkipServiceParamSet = true
+ };
+
+ // Act
+ await sut.Command.ExecuteAsync(options);
+
+ // Assert
+ sut.Output.ToString().Should().Contain("--skip-service-params overrides SERVICE in --param-sets.");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_SkipServiceParamsWithoutServiceInWhitelist_DoesNotPrintOverrideWarning()
+ {
+ // Arrange
+ var sut = CreateSut(BuildExportData(new ParamSetExportData
+ {
+ ParamSetKey = "MASTER",
+ Values = []
+ }));
+ var options = new ShowDeviceDetailsOptions
+ {
+ Address = DeviceAddress,
+ ParamSets = "MASTER",
+ SkipServiceParamSet = true
+ };
+
+ // Act
+ await sut.Command.ExecuteAsync(options);
+
+ // Assert - no contradiction, so no warning.
+ sut.Output.ToString().Should().NotContain("overrides SERVICE");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_ServiceInWhitelistWithoutSkipServiceParams_DoesNotPrintOverrideWarning()
+ {
+ // Arrange
+ var sut = CreateSut(BuildExportData(new ParamSetExportData
+ {
+ ParamSetKey = "SERVICE",
+ Values = []
+ }));
+ var options = new ShowDeviceDetailsOptions
+ {
+ Address = DeviceAddress,
+ ParamSets = "SERVICE"
+ };
+
+ // Act
+ await sut.Command.ExecuteAsync(options);
+
+ // Assert
+ sut.Output.ToString().Should().NotContain("overrides SERVICE");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_SkipServiceParamsWithoutWhitelist_DoesNotPrintOverrideWarning()
+ {
+ // Arrange
+ var sut = CreateSut(BuildExportData(new ParamSetExportData
+ {
+ ParamSetKey = "MASTER",
+ Values = []
+ }));
+ var options = new ShowDeviceDetailsOptions
+ {
+ Address = DeviceAddress,
+ SkipServiceParamSet = true
+ };
+
+ // Act
+ await sut.Command.ExecuteAsync(options);
+
+ // Assert - nothing to override without a --param-sets whitelist.
+ sut.Output.ToString().Should().NotContain("overrides SERVICE");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_SkipServiceParamsAndOnlyServiceInWhitelist_SuppressesFilterWarning()
+ {
+ // Arrange - the whitelist matches only SERVICE, which the skip then removes, so nothing is left to show.
+ var sut = CreateSut(BuildExportData());
+ var options = new ShowDeviceDetailsOptions
+ {
+ Address = DeviceAddress,
+ ParamSets = "SERVICE",
+ SkipServiceParamSet = true
+ };
+
+ // Act
+ await sut.Command.ExecuteAsync(options);
+
+ // Assert - blaming the --param-sets filter would be wrong; the override warning already explains it.
+ var output = sut.Output.ToString();
+ output.Should().Contain("--skip-service-params overrides SERVICE in --param-sets.");
+ output.Should().NotContain("No ParamSets matched");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_SkipServiceParamsAndSurvivingWhitelistEntryMatchesNothing_PrintsBothWarnings()
+ {
+ // Arrange - SERVICE is overridden by the skip, but MASTER survives the skip and matches nothing.
+ var sut = CreateSut(BuildExportData());
+ var options = new ShowDeviceDetailsOptions
+ {
+ Address = DeviceAddress,
+ ParamSets = "MASTER,SERVICE",
+ SkipServiceParamSet = true
+ };
+
+ // Act
+ await sut.Command.ExecuteAsync(options);
+
+ // Assert - the override note must not swallow the legitimate filter warning for MASTER.
+ var output = sut.Output.ToString();
+ output.Should().Contain("--skip-service-params overrides SERVICE in --param-sets.");
+ output.Should().Contain("No ParamSets matched the --param-sets filter.");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_WhitelistMatchesNothingWithoutSkip_StillPrintsFilterWarning()
+ {
+ // Arrange - no contradiction, so the filter warning must not be suppressed.
+ var sut = CreateSut(BuildExportData());
+ var options = new ShowDeviceDetailsOptions
+ {
+ Address = DeviceAddress,
+ ParamSets = "SERVICE",
+ SkipServiceParamSet = false
+ };
+
+ // Act
+ await sut.Command.ExecuteAsync(options);
+
+ // Assert
+ sut.Output.ToString().Should().Contain("No ParamSets matched the --param-sets filter.");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_OnlyChannelsOfferParamSets_PrintsFilterWarning()
+ {
+ // Arrange - the device itself has no ParamSets, but a channel does, so the filter did remove something.
+ var sut = CreateSut(BuildExportData(paramSetKeys: []), snapshotParamSets: [],
+ snapshotChannelParamSets: ["VALUES"]);
+ var options = new ShowDeviceDetailsOptions
+ {
+ Address = DeviceAddress,
+ ParamSets = "MASTRE"
+ };
+
+ // Act
+ await sut.Command.ExecuteAsync(options);
+
+ // Assert
+ sut.Output.ToString().Should().Contain("No ParamSets matched the --param-sets filter.");
+ }
+
private static DeviceExportData BuildExportData(params ParamSetExportData[] paramSets)
{
return BuildExportData(["MASTER", "SERVICE"], paramSets);
@@ -241,7 +452,8 @@ private static DeviceExportData BuildExportData(string[] paramSetKeys,
};
}
- private static SutContext CreateSut(DeviceExportData exportData)
+ private static SutContext CreateSut(DeviceExportData exportData, string[]? snapshotParamSets = null,
+ string[]? snapshotChannelParamSets = null)
{
var output = new StringWriter();
var console = AnsiConsole.Create(new AnsiConsoleSettings
@@ -253,13 +465,14 @@ private static SutContext CreateSut(DeviceExportData exportData)
});
var context = new SutContext(output);
+ var snapshot = CreateSnapshot(snapshotParamSets ?? ["MASTER", "SERVICE"], snapshotChannelParamSets ?? []);
var multiCcuClient = A.Fake();
A.CallTo(() => multiCcuClient.GetCompleteDeviceAsync(DeviceAddress, A._))
.ReturnsLazily((string _, CompleteCcuDeviceBuildOptions buildOptions) =>
{
context.CapturedBuildOptions = buildOptions;
- return Task.FromResult(A.Fake());
+ return Task.FromResult(snapshot);
});
var deviceExporter = A.Fake();
@@ -275,6 +488,35 @@ private static SutContext CreateSut(DeviceExportData exportData)
return context;
}
+ ///
+ /// Builds the unfiltered device snapshot. The command reads its ParamSet keys from here to decide whether the
+ /// device offers any ParamSets at all — the export data cannot answer that, because its key lists are filtered.
+ ///
+ private static ICompleteCcuDevice CreateSnapshot(string[] paramSets, string[] channelParamSets)
+ {
+ var deviceData = A.Fake();
+ A.CallTo(() => deviceData.ParamSets).Returns(paramSets);
+
+ var snapshot = A.Fake();
+ A.CallTo(() => snapshot.DeviceData).Returns(deviceData);
+ A.CallTo(() => snapshot.Channels).Returns(channelParamSets.Length == 0
+ ? []
+ : [CreateSnapshotChannel(channelParamSets)]);
+
+ return snapshot;
+ }
+
+ private static ICompleteCcuDeviceChannel CreateSnapshotChannel(string[] paramSets)
+ {
+ var channelData = A.Fake();
+ A.CallTo(() => channelData.ParamSets).Returns(paramSets);
+
+ var channel = A.Fake();
+ A.CallTo(() => channel.ChannelData).Returns(channelData);
+
+ return channel;
+ }
+
private sealed class SutContext(StringWriter output)
{
public ShowDeviceDetailsCommand Command { get; set; } = null!;
From 531d6b45254dd353ddcb239a22ddbe87e0aee9b0 Mon Sep 17 00:00:00 2001
From: darthsharp <48331467+darthsharp@users.noreply.github.com>
Date: Sun, 12 Jul 2026 20:57:09 +0200
Subject: [PATCH 3/3] Remove unused `CreativeCoders.SysConsole.Core` import
from `ShowDeviceDetailsCommand`.
---
.../Device/ShowDetails/ShowDeviceDetailsCommand.cs | 1 -
1 file changed, 1 deletion(-)
diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/ShowDetails/ShowDeviceDetailsCommand.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/ShowDetails/ShowDeviceDetailsCommand.cs
index 82247ad..d9238bb 100644
--- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/ShowDetails/ShowDeviceDetailsCommand.cs
+++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Device/ShowDetails/ShowDeviceDetailsCommand.cs
@@ -3,7 +3,6 @@
using CreativeCoders.HomeMatic.Core;
using CreativeCoders.HomeMatic.Core.Parameters;
using CreativeCoders.HomeMatic.Exporting;
-using CreativeCoders.SysConsole.Core;
using JetBrains.Annotations;
using Spectre.Console;