diff --git a/source/CreativeCoders.HomeMatic.Core/CompleteCcuDeviceBuildOptions.cs b/source/CreativeCoders.HomeMatic.Core/CompleteCcuDeviceBuildOptions.cs index 055c5f0..cf71a07 100644 --- a/source/CreativeCoders.HomeMatic.Core/CompleteCcuDeviceBuildOptions.cs +++ b/source/CreativeCoders.HomeMatic.Core/CompleteCcuDeviceBuildOptions.cs @@ -1,3 +1,6 @@ +using System; +using System.Collections.Generic; +using CreativeCoders.HomeMatic.Core.Parameters; using CreativeCoders.HomeMatic.XmlRpc.Links; using JetBrains.Annotations; @@ -21,4 +24,38 @@ 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; } + + /// + /// Gets or sets a value indicating whether the ParamSet is skipped + /// instead of being fetched from the CCU. + /// + /// 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 ParamSetFilter.IsParamSetAllowed(ParamSetWhitelist, SkipServiceParamSet, 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/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.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/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 d17267d..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,18 +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) { - if (ParamSetWhitelist is null || ParamSetWhitelist.Count == 0) - { - return true; - } - - return ParamSetWhitelist.Contains(paramSetKey, StringComparer.OrdinalIgnoreCase); + return ParamSetFilter.IsParamSetAllowed(ParamSetWhitelist, SkipServiceParamSet, paramSetKey); } /// @@ -62,24 +78,23 @@ 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 and so filtered ParamSets + /// are not fetched at all. public CompleteCcuDeviceBuildOptions ToBuildOptions() { return new CompleteCcuDeviceBuildOptions { IncludeLinks = IncludeLinks, - LinksFlags = LinksFlags + LinksFlags = LinksFlags, + ParamSetWhitelist = ParamSetWhitelist, + SkipServiceParamSet = SkipServiceParamSet }; } } diff --git a/source/CreativeCoders.HomeMatic/Exporting/DeviceExporter.cs b/source/CreativeCoders.HomeMatic/Exporting/DeviceExporter.cs index 4ff5800..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 @@ -88,9 +95,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/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 adeae13..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 @@ -1,8 +1,8 @@ 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; using Spectre.Console; @@ -26,7 +26,11 @@ public async Task ExecuteAsync(ShowDeviceDetailsOptions options) { var exportOptions = new DeviceExportOptions { - IncludeLinks = true + IncludeLinks = true, + SkipServiceParamSet = options.SkipServiceParamSet, + ParamSetWhitelist = string.IsNullOrWhiteSpace(options.ParamSets) + ? null + : options.ParamSets.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) }; var device = await _multiCcuClient @@ -38,6 +42,37 @@ public async Task ExecuteAsync(ShowDeviceDetailsOptions options) _console.WriteLine($"Show device details for '{options.Address}'"); _console.WriteLine(); + 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()) + && deviceOffersParamSets) + { + _console.MarkupLine("[yellow]No ParamSets matched the --param-sets filter.[/]"); + _console.WriteLine(); + } + PrintDevice(exportData); return CommandResult.Success; @@ -87,6 +122,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..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 @@ -9,4 +9,21 @@ 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; + + /// + /// 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/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..5a28240 --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.Tests/CompleteCcuDeviceBuildOptionsTests.cs @@ -0,0 +1,143 @@ +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); + } + + [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 f56484b..429fab7 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,368 @@ 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_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() + { + // 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 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() + { + // 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.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 new file mode 100644 index 0000000..5d0d8e9 --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.Tools.Cli.Commands.Tests/Device/ShowDetails/ShowDeviceDetailsCommandTests.cs @@ -0,0 +1,530 @@ +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: []), snapshotParamSets: []); + 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_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); + } + + 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, string[]? snapshotParamSets = null, + string[]? snapshotChannelParamSets = null) + { + 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 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(snapshot); + }); + + 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; + } + + /// + /// 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!; + + public StringWriter Output { get; } = output; + + public CompleteCcuDeviceBuildOptions? CapturedBuildOptions { get; set; } + + public DeviceExportOptions? CapturedExportOptions { get; set; } + } +}