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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
using System;
using System.Collections.Generic;
using CreativeCoders.HomeMatic.Core.Parameters;
using CreativeCoders.HomeMatic.XmlRpc.Links;
using JetBrains.Annotations;

Expand All @@ -21,4 +24,38 @@ public class CompleteCcuDeviceBuildOptions
/// </summary>
/// <value>The <see cref="GetLinksFlags"/> value. Default is <see cref="GetLinksFlags.None"/>.</value>
public GetLinksFlags LinksFlags { get; set; } = GetLinksFlags.None;

/// <summary>
/// Whitelist of ParamSet keys to fetch from the CCU (e.g. "MASTER", "VALUES").
/// If empty or null, all ParamSets are fetched.
/// </summary>
/// <value>The collection of allowed ParamSet keys, or <see langword="null"/> to fetch all.</value>
public ICollection<string>? ParamSetWhitelist { get; set; }

/// <summary>
/// Gets or sets a value indicating whether the <see cref="ParamSetKey.Service"/> ParamSet is skipped
/// instead of being fetched from the CCU.
/// </summary>
/// <value><see langword="true"/> to skip the SERVICE ParamSet; otherwise, <see langword="false"/>.
/// Default is <see langword="false"/>.</value>
/// <remarks>
/// Skipping takes precedence over <see cref="ParamSetWhitelist"/>: 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.
/// </remarks>
public bool SkipServiceParamSet { get; set; }

/// <summary>
/// Determines whether a ParamSet may be fetched from the CCU.
/// </summary>
/// <param name="paramSetKey">The ParamSet key to check. The comparison is case-insensitive.</param>
/// <returns><see langword="true"/> if the ParamSet may be fetched; otherwise, <see langword="false"/>.
/// The SERVICE ParamSet is never allowed while <see cref="SkipServiceParamSet"/> is <see langword="true"/>,
/// even when the <see cref="ParamSetWhitelist"/> contains it.</returns>
/// <exception cref="ArgumentNullException"><paramref name="paramSetKey"/> is <see langword="null"/>.</exception>
public bool IsParamSetAllowed(string paramSetKey)
{
return ParamSetFilter.IsParamSetAllowed(ParamSetWhitelist, SkipServiceParamSet, paramSetKey);
}
}
21 changes: 21 additions & 0 deletions source/CreativeCoders.HomeMatic.Core/DeviceNotFoundException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using CreativeCoders.Core;
using CreativeCoders.HomeMatic.XmlRpc.Exceptions;
using JetBrains.Annotations;

namespace CreativeCoders.HomeMatic.Core;

/// <summary>
/// Exception that is thrown when a device or channel address cannot be resolved.
/// </summary>
/// <param name="address">The device or channel address that could not be found.</param>
/// <param name="message">An optional error message. If omitted, a default message containing the address is used.</param>
[PublicAPI]
public class DeviceNotFoundException(string address, string? message = null)
: HomeMaticException(message ?? $"Device with address '{address}' not found.")
{
/// <summary>
/// Gets the device or channel address that could not be found.
/// </summary>
/// <value>The device or channel address.</value>
public string Address { get; } = Ensure.NotNull(address);
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public class ParamSetValueWithDescription
/// <summary>
/// Gets the description that belongs to the parameter.
/// </summary>
/// <value>The <see cref="CcuParameterDescription"/> instance.</value>
public required CcuParameterDescription Description { get; init; }
/// <value>The <see cref="CcuParameterDescription"/> instance, or <see langword="null"/> if the CCU
/// did not provide a description for the parameter.</value>
public required CcuParameterDescription? Description { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,10 @@ public class ParamSetValuesWithDescriptions
/// </summary>
/// <value>The enumerable of <see cref="ParamSetValueWithDescription"/> entries.</value>
public required IEnumerable<ParamSetValueWithDescription> ParamSetValues { get; init; }

/// <summary>
/// Gets the error message if reading the parameter-set values from the CCU failed.
/// </summary>
/// <value>The error message, or <see langword="null"/> if the values were read successfully.</value>
public string? ReadError { get; init; }
}
2 changes: 2 additions & 0 deletions source/CreativeCoders.HomeMatic.Core/ICcuClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public interface ICcuClient
/// </summary>
/// <param name="address">The device address.</param>
/// <returns>A task that yields the matching <see cref="ICcuDevice"/>.</returns>
/// <exception cref="DeviceNotFoundException">Thrown when the CCU does not know a device with the given address.</exception>
Task<ICcuDevice> GetDeviceAsync(string address);

/// <summary>
Expand All @@ -38,6 +39,7 @@ Task<IEnumerable<ICompleteCcuDevice>> GetCompleteDevicesAsync(
/// <param name="address">The device address.</param>
/// <param name="buildOptions">Optional build options controlling whether links are fetched.</param>
/// <returns>A task that yields the matching <see cref="ICompleteCcuDevice"/>.</returns>
/// <exception cref="DeviceNotFoundException">Thrown when the CCU does not know a device with the given address.</exception>
Task<ICompleteCcuDevice> GetCompleteDeviceAsync(string address,
CompleteCcuDeviceBuildOptions? buildOptions = null);

Expand Down
2 changes: 2 additions & 0 deletions source/CreativeCoders.HomeMatic.Core/IMultiCcuClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public interface IMultiCcuClient
/// </summary>
/// <param name="address">The device address.</param>
/// <returns>A task that yields the matching <see cref="ICcuDevice"/>.</returns>
/// <exception cref="DeviceNotFoundException">Thrown when no configured CCU knows a device with the given address.</exception>
Task<ICcuDevice> GetDeviceAsync(string address);

/// <summary>
Expand All @@ -38,6 +39,7 @@ Task<IEnumerable<ICompleteCcuDevice>> GetCompleteDevicesAsync(
/// <param name="address">The device address.</param>
/// <param name="buildOptions">Optional build options controlling whether links are fetched.</param>
/// <returns>A task that yields the matching <see cref="ICompleteCcuDevice"/>.</returns>
/// <exception cref="DeviceNotFoundException">Thrown when no configured CCU knows a device with the given address.</exception>
Task<ICompleteCcuDevice> GetCompleteDeviceAsync(string address,
CompleteCcuDeviceBuildOptions? buildOptions = null);
}
44 changes: 44 additions & 0 deletions source/CreativeCoders.HomeMatic.Core/ParamSetFilter.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Provides the shared filter semantics that decide whether a ParamSet may be used.
/// </summary>
/// <remarks>
/// The filter combines two independent rules: skipping the <see cref="ParamSetKey.Service"/> ParamSet and an
/// optional whitelist. Skipping takes precedence, so the SERVICE ParamSet is rejected even when the whitelist
/// explicitly contains it.
/// </remarks>
[PublicAPI]
public static class ParamSetFilter
{
/// <summary>
/// Determines whether a ParamSet may be used.
/// </summary>
/// <param name="whitelist">The allowed ParamSet keys, or <see langword="null"/> for no whitelist filtering.</param>
/// <param name="skipServiceParamSet"><see langword="true"/> to reject the <see cref="ParamSetKey.Service"/>
/// ParamSet regardless of the whitelist; otherwise, <see langword="false"/>.</param>
/// <param name="paramSetKey">The ParamSet key to check. The comparison is case-insensitive.</param>
/// <returns><see langword="true"/> if the ParamSet may be used; otherwise, <see langword="false"/>.
/// The SERVICE ParamSet is never allowed while <paramref name="skipServiceParamSet"/> is
/// <see langword="true"/>, even when <paramref name="whitelist"/> contains it.</returns>
/// <exception cref="ArgumentNullException"><paramref name="paramSetKey"/> is <see langword="null"/>.</exception>
public static bool IsParamSetAllowed(ICollection<string>? whitelist, bool skipServiceParamSet,
string paramSetKey)
{
Ensure.NotNull(paramSetKey);

if (skipServiceParamSet
&& string.Equals(paramSetKey, ParamSetKey.Service, StringComparison.OrdinalIgnoreCase))
{
return false;
}

return WhitelistFilter.IsAllowed(whitelist, paramSetKey);
}
}
33 changes: 33 additions & 0 deletions source/CreativeCoders.HomeMatic.Core/WhitelistFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CreativeCoders.Core;
using JetBrains.Annotations;

namespace CreativeCoders.HomeMatic.Core;

/// <summary>
/// Provides the shared whitelist semantics used by ParamSet and parameter-value filters.
/// </summary>
[PublicAPI]
public static class WhitelistFilter
{
/// <summary>
/// Determines whether a key is allowed based on the given whitelist.
/// </summary>
/// <param name="whitelist">The whitelist to check against, or <see langword="null"/> for no filtering.</param>
/// <param name="key">The key to check. The comparison is case-insensitive.</param>
/// <returns><see langword="true"/> if the key is contained in the whitelist or the whitelist is
/// <see langword="null"/> or empty; otherwise, <see langword="false"/>.</returns>
public static bool IsAllowed(ICollection<string>? whitelist, string key)
{
Ensure.NotNull(key);

if (whitelist is null || whitelist.Count == 0)
{
return true;
}

return whitelist.Contains(key, StringComparer.OrdinalIgnoreCase);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ namespace CreativeCoders.HomeMatic.XmlRpc.Exceptions;
/// </summary>
public abstract class HomeMaticException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="HomeMaticException"/> class.
/// </summary>
/// <param name="message">The error message that explains the reason for the exception.</param>
protected HomeMaticException(string message) : base(message)
{
}

/// <summary>
/// Initializes a new instance of the <see cref="HomeMaticException"/> class.
/// </summary>
Expand Down
2 changes: 1 addition & 1 deletion source/CreativeCoders.HomeMatic/CcuClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ public async Task<ICcuDevice> 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);
}

/// <inheritdoc />
Expand Down
5 changes: 3 additions & 2 deletions source/CreativeCoders.HomeMatic/CcuDevice.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using CreativeCoders.HomeMatic.Core;
using CreativeCoders.HomeMatic.Core.Devices;
using CreativeCoders.HomeMatic.XmlRpc.Client;
using CreativeCoders.HomeMatic.XmlRpc.Devices;
Expand Down Expand Up @@ -40,13 +41,13 @@ public class CcuDevice(IHomeMaticXmlRpcApi api) : CcuDeviceBase(api), ICcuDevice
/// </summary>
/// <param name="channelAddress">The full address of the channel (for example <c>"ABC0001234:1"</c>).</param>
/// <returns>A task that yields the matching <see cref="ICcuDeviceChannel"/>.</returns>
/// <exception cref="KeyNotFoundException">Thrown when no channel with the specified address exists on this device.</exception>
/// <exception cref="DeviceNotFoundException">Thrown when no channel with the specified address exists on this device.</exception>
public Task<ICcuDeviceChannel> 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.");
}
}
86 changes: 71 additions & 15 deletions source/CreativeCoders.HomeMatic/CompleteCcuDeviceBuilder.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -11,6 +13,8 @@ namespace CreativeCoders.HomeMatic;
/// </summary>
public class CompleteCcuDeviceBuilder : ICompleteCcuDeviceBuilder
{
private const int FaultCodeDeviceNotReachable = -321;

/// <inheritdoc />
public async Task<ICompleteCcuDevice> BuildAsync(ICcuDevice device, CompleteCcuDeviceBuildOptions? options = null)
{
Expand All @@ -20,7 +24,7 @@ public async Task<ICompleteCcuDevice> BuildAsync(ICcuDevice device, CompleteCcuD
{
DeviceData = device,
Channels = channels,
ParamSetValues = await GetParamSetValuesAsync(device).ConfigureAwait(false)
ParamSetValues = await GetParamSetValuesAsync(device, options).ConfigureAwait(false)
};

return completeDevice;
Expand All @@ -40,7 +44,7 @@ private static async Task<IEnumerable<ICompleteCcuDeviceChannel>> GetChannelsAsy
var completeChannel = new CompleteCcuDeviceChannel
{
ChannelData = ccuDeviceChannel,
ParamSetValues = await GetParamSetValuesAsync(ccuDeviceChannel).ConfigureAwait(false),
ParamSetValues = await GetParamSetValuesAsync(ccuDeviceChannel, options).ConfigureAwait(false),
Links = links
};

Expand All @@ -50,29 +54,81 @@ private static async Task<IEnumerable<ICompleteCcuDeviceChannel>> GetChannelsAsy
return [..channels];
}

private static async Task<IEnumerable<ParamSetValuesWithDescriptions>> GetParamSetValuesAsync(ICcuDeviceBase device)
private static async Task<IEnumerable<ParamSetValuesWithDescriptions>> GetParamSetValuesAsync(
ICcuDeviceBase device, CompleteCcuDeviceBuildOptions? options)
{
var paramSetValues = new List<ParamSetValuesWithDescriptions>();

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}";
}
}
Loading
Loading