diff --git a/.serena/.gitignore b/.serena/.gitignore
new file mode 100644
index 0000000..2e510af
--- /dev/null
+++ b/.serena/.gitignore
@@ -0,0 +1,2 @@
+/cache
+/project.local.yml
diff --git a/.serena/project.yml b/.serena/project.yml
new file mode 100644
index 0000000..213ff16
--- /dev/null
+++ b/.serena/project.yml
@@ -0,0 +1,154 @@
+# the name by which the project can be referenced within Serena
+project_name: "HomeMatic"
+
+
+# list of languages for which language servers are started; choose from:
+# al bash clojure cpp csharp
+# csharp_omnisharp dart elixir elm erlang
+# fortran fsharp go groovy haskell
+# haxe java julia kotlin lua
+# markdown
+# matlab nix pascal perl php
+# php_phpactor powershell python python_jedi r
+# rego ruby ruby_solargraph rust scala
+# swift terraform toml typescript typescript_vts
+# vue yaml zig
+# (This list may be outdated. For the current list, see values of Language enum here:
+# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
+# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
+# Note:
+# - For C, use cpp
+# - For JavaScript, use typescript
+# - For Free Pascal/Lazarus, use pascal
+# Special requirements:
+# Some languages require additional setup/installations.
+# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
+# When using multiple languages, the first language server that supports a given file will be used for that file.
+# The first language is the default language and the respective language server will be used as a fallback.
+# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
+languages:
+- csharp
+
+# the encoding used by text files in the project
+# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
+encoding: "utf-8"
+
+# line ending convention to use when writing source files.
+# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
+# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
+line_ending:
+
+# The language backend to use for this project.
+# If not set, the global setting from serena_config.yml is used.
+# Valid values: LSP, JetBrains
+# Note: the backend is fixed at startup. If a project with a different backend
+# is activated post-init, an error will be returned.
+language_backend:
+
+# whether to use project's .gitignore files to ignore files
+ignore_all_files_in_gitignore: true
+
+# advanced configuration option allowing to configure language server-specific options.
+# Maps the language key to the options.
+# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
+# No documentation on options means no options are available.
+ls_specific_settings: {}
+
+# list of additional paths to ignore in this project.
+# Same syntax as gitignore, so you can use * and **.
+# Note: global ignored_paths from serena_config.yml are also applied additively.
+ignored_paths: []
+
+# whether the project is in read-only mode
+# If set to true, all editing tools will be disabled and attempts to use them will result in an error
+# Added on 2025-04-18
+read_only: false
+
+# list of tool names to exclude.
+# This extends the existing exclusions (e.g. from the global configuration)
+#
+# Below is the complete list of tools for convenience.
+# To make sure you have the latest list of tools, and to view their descriptions,
+# execute `uv run scripts/print_tool_overview.py`.
+#
+# * `activate_project`: Activates a project based on the project name or path.
+# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
+# * `create_text_file`: Creates/overwrites a file in the project directory.
+# * `delete_memory`: Delete a memory file. Should only happen if a user asks for it explicitly,
+# for example by saying that the information retrieved from a memory file is no longer correct
+# or no longer relevant for the project.
+# * `edit_memory`: Replaces content matching a regular expression in a memory.
+# * `execute_shell_command`: Executes a shell command.
+# * `find_file`: Finds files in the given relative paths
+# * `find_referencing_symbols`: Finds symbols that reference the given symbol using the language server backend
+# * `find_symbol`: Performs a global (or local) search using the language server backend.
+# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
+# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
+# * `initial_instructions`: Provides instructions Serena usage (i.e. the 'Serena Instructions Manual')
+# for clients that do not read the initial instructions when the MCP server is connected.
+# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
+# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
+# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
+# * `list_memories`: List available memories. Any memory can be read using the `read_memory` tool.
+# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
+# * `read_file`: Reads a file within the project directory.
+# * `read_memory`: Read the content of a memory file. This tool should only be used if the information
+# is relevant to the current task. You can infer whether the information
+# is relevant from the memory file name.
+# You should not read the same memory file multiple times in the same conversation.
+# * `rename_memory`: Renames or moves a memory. Moving between project and global scope is supported
+# (e.g., renaming "global/foo" to "bar" moves it from global to project scope).
+# * `rename_symbol`: Renames a symbol throughout the codebase using language server refactoring capabilities.
+# For JB, we use a separate tool.
+# * `replace_content`: Replaces content in a file (optionally using regular expressions).
+# * `replace_symbol_body`: Replaces the full definition of a symbol using the language server backend.
+# * `safe_delete_symbol`:
+# * `search_for_pattern`: Performs a search for a pattern in the project.
+# * `write_memory`: Write some information (utf-8-encoded) about this project that can be useful for future tasks to a memory in md format.
+# The memory name should be meaningful.
+excluded_tools: []
+
+# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
+# This extends the existing inclusions (e.g. from the global configuration).
+included_optional_tools: []
+
+# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
+# This cannot be combined with non-empty excluded_tools or included_optional_tools.
+fixed_tools: []
+
+# list of mode names to that are always to be included in the set of active modes
+# The full set of modes to be activated is base_modes + default_modes.
+# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
+# Otherwise, this setting overrides the global configuration.
+# Set this to [] to disable base modes for this project.
+# Set this to a list of mode names to always include the respective modes for this project.
+base_modes:
+
+# list of mode names that are to be activated by default.
+# The full set of modes to be activated is base_modes + default_modes.
+# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
+# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
+# This setting can, in turn, be overridden by CLI parameters (--mode).
+default_modes:
+
+# initial prompt for the project. It will always be given to the LLM upon activating the project
+# (contrary to the memories, which are loaded on demand).
+initial_prompt: ""
+
+# time budget (seconds) per tool call for the retrieval of additional symbol information
+# such as docstrings or parameter information.
+# This overrides the corresponding setting in the global configuration; see the documentation there.
+# If null or missing, use the setting from the global configuration.
+symbol_info_budget:
+
+# list of regex patterns which, when matched, mark a memory entry as read‑only.
+# Extends the list from the global configuration, merging the two lists.
+read_only_memory_patterns: []
+
+# list of regex patterns for memories to completely ignore.
+# Matching memories will not appear in list_memories or activate_project output
+# and cannot be accessed via read_memory or write_memory.
+# To access ignored memory files, use the read_file tool on the raw file path.
+# Extends the list from the global configuration, merging the two lists.
+# Example: ["_archive/.*", "_episodes/.*"]
+ignored_memory_patterns: []
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 0d9d967..93b3b95 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -24,5 +24,6 @@
+
\ No newline at end of file
diff --git a/HomeMatic.sln b/HomeMatic.sln
index 9b7c40b..464f2a1 100644
--- a/HomeMatic.sln
+++ b/HomeMatic.sln
@@ -80,66 +80,186 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__docs", "__docs", "{246443
docs\HomeMatic-XmlRpc.md = docs\HomeMatic-XmlRpc.md
EndProjectSection
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.HomeMatic.Tools.Cli.Base.Tests", "tests\CreativeCoders.HomeMatic.Tools.Cli.Base.Tests\CreativeCoders.HomeMatic.Tools.Cli.Base.Tests.csproj", "{E3F3D28A-919C-4283-AEBD-A915A3EFE047}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.HomeMatic.XmlRpc.Tests", "tests\CreativeCoders.HomeMatic.XmlRpc.Tests\CreativeCoders.HomeMatic.XmlRpc.Tests.csproj", "{5614CD87-E146-4D66-A199-5F110B2A0445}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{33773A69-F53A-4BC8-B3B9-564C8C9B0E23}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{33773A69-F53A-4BC8-B3B9-564C8C9B0E23}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {33773A69-F53A-4BC8-B3B9-564C8C9B0E23}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {33773A69-F53A-4BC8-B3B9-564C8C9B0E23}.Debug|x64.Build.0 = Debug|Any CPU
+ {33773A69-F53A-4BC8-B3B9-564C8C9B0E23}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {33773A69-F53A-4BC8-B3B9-564C8C9B0E23}.Debug|x86.Build.0 = Debug|Any CPU
{33773A69-F53A-4BC8-B3B9-564C8C9B0E23}.Release|Any CPU.ActiveCfg = Release|Any CPU
{33773A69-F53A-4BC8-B3B9-564C8C9B0E23}.Release|Any CPU.Build.0 = Release|Any CPU
+ {33773A69-F53A-4BC8-B3B9-564C8C9B0E23}.Release|x64.ActiveCfg = Release|Any CPU
+ {33773A69-F53A-4BC8-B3B9-564C8C9B0E23}.Release|x64.Build.0 = Release|Any CPU
+ {33773A69-F53A-4BC8-B3B9-564C8C9B0E23}.Release|x86.ActiveCfg = Release|Any CPU
+ {33773A69-F53A-4BC8-B3B9-564C8C9B0E23}.Release|x86.Build.0 = Release|Any CPU
{C8E5E8C0-FAA5-49D3-B121-F5E5BBF7C0EF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C8E5E8C0-FAA5-49D3-B121-F5E5BBF7C0EF}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C8E5E8C0-FAA5-49D3-B121-F5E5BBF7C0EF}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {C8E5E8C0-FAA5-49D3-B121-F5E5BBF7C0EF}.Debug|x64.Build.0 = Debug|Any CPU
+ {C8E5E8C0-FAA5-49D3-B121-F5E5BBF7C0EF}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {C8E5E8C0-FAA5-49D3-B121-F5E5BBF7C0EF}.Debug|x86.Build.0 = Debug|Any CPU
{C8E5E8C0-FAA5-49D3-B121-F5E5BBF7C0EF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C8E5E8C0-FAA5-49D3-B121-F5E5BBF7C0EF}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C8E5E8C0-FAA5-49D3-B121-F5E5BBF7C0EF}.Release|x64.ActiveCfg = Release|Any CPU
+ {C8E5E8C0-FAA5-49D3-B121-F5E5BBF7C0EF}.Release|x64.Build.0 = Release|Any CPU
+ {C8E5E8C0-FAA5-49D3-B121-F5E5BBF7C0EF}.Release|x86.ActiveCfg = Release|Any CPU
+ {C8E5E8C0-FAA5-49D3-B121-F5E5BBF7C0EF}.Release|x86.Build.0 = Release|Any CPU
{5F02B2B8-FC8C-44D8-A0CD-E9E31115EAB5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5F02B2B8-FC8C-44D8-A0CD-E9E31115EAB5}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {5F02B2B8-FC8C-44D8-A0CD-E9E31115EAB5}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {5F02B2B8-FC8C-44D8-A0CD-E9E31115EAB5}.Debug|x64.Build.0 = Debug|Any CPU
+ {5F02B2B8-FC8C-44D8-A0CD-E9E31115EAB5}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {5F02B2B8-FC8C-44D8-A0CD-E9E31115EAB5}.Debug|x86.Build.0 = Debug|Any CPU
{5F02B2B8-FC8C-44D8-A0CD-E9E31115EAB5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5F02B2B8-FC8C-44D8-A0CD-E9E31115EAB5}.Release|Any CPU.Build.0 = Release|Any CPU
+ {5F02B2B8-FC8C-44D8-A0CD-E9E31115EAB5}.Release|x64.ActiveCfg = Release|Any CPU
+ {5F02B2B8-FC8C-44D8-A0CD-E9E31115EAB5}.Release|x64.Build.0 = Release|Any CPU
+ {5F02B2B8-FC8C-44D8-A0CD-E9E31115EAB5}.Release|x86.ActiveCfg = Release|Any CPU
+ {5F02B2B8-FC8C-44D8-A0CD-E9E31115EAB5}.Release|x86.Build.0 = Release|Any CPU
{1DB2232F-17CF-40AF-9190-09441C860BEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1DB2232F-17CF-40AF-9190-09441C860BEB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1DB2232F-17CF-40AF-9190-09441C860BEB}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {1DB2232F-17CF-40AF-9190-09441C860BEB}.Debug|x64.Build.0 = Debug|Any CPU
+ {1DB2232F-17CF-40AF-9190-09441C860BEB}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {1DB2232F-17CF-40AF-9190-09441C860BEB}.Debug|x86.Build.0 = Debug|Any CPU
{1DB2232F-17CF-40AF-9190-09441C860BEB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1DB2232F-17CF-40AF-9190-09441C860BEB}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1DB2232F-17CF-40AF-9190-09441C860BEB}.Release|x64.ActiveCfg = Release|Any CPU
+ {1DB2232F-17CF-40AF-9190-09441C860BEB}.Release|x64.Build.0 = Release|Any CPU
+ {1DB2232F-17CF-40AF-9190-09441C860BEB}.Release|x86.ActiveCfg = Release|Any CPU
+ {1DB2232F-17CF-40AF-9190-09441C860BEB}.Release|x86.Build.0 = Release|Any CPU
{15C3A03E-F2A5-4497-B0DE-A9EB73C9E6AF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{15C3A03E-F2A5-4497-B0DE-A9EB73C9E6AF}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {15C3A03E-F2A5-4497-B0DE-A9EB73C9E6AF}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {15C3A03E-F2A5-4497-B0DE-A9EB73C9E6AF}.Debug|x64.Build.0 = Debug|Any CPU
+ {15C3A03E-F2A5-4497-B0DE-A9EB73C9E6AF}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {15C3A03E-F2A5-4497-B0DE-A9EB73C9E6AF}.Debug|x86.Build.0 = Debug|Any CPU
{15C3A03E-F2A5-4497-B0DE-A9EB73C9E6AF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{15C3A03E-F2A5-4497-B0DE-A9EB73C9E6AF}.Release|Any CPU.Build.0 = Release|Any CPU
+ {15C3A03E-F2A5-4497-B0DE-A9EB73C9E6AF}.Release|x64.ActiveCfg = Release|Any CPU
+ {15C3A03E-F2A5-4497-B0DE-A9EB73C9E6AF}.Release|x64.Build.0 = Release|Any CPU
+ {15C3A03E-F2A5-4497-B0DE-A9EB73C9E6AF}.Release|x86.ActiveCfg = Release|Any CPU
+ {15C3A03E-F2A5-4497-B0DE-A9EB73C9E6AF}.Release|x86.Build.0 = Release|Any CPU
{91834652-D1F2-4563-B684-AAF776E6B341}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{91834652-D1F2-4563-B684-AAF776E6B341}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {91834652-D1F2-4563-B684-AAF776E6B341}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {91834652-D1F2-4563-B684-AAF776E6B341}.Debug|x64.Build.0 = Debug|Any CPU
+ {91834652-D1F2-4563-B684-AAF776E6B341}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {91834652-D1F2-4563-B684-AAF776E6B341}.Debug|x86.Build.0 = Debug|Any CPU
{91834652-D1F2-4563-B684-AAF776E6B341}.Release|Any CPU.ActiveCfg = Release|Any CPU
{91834652-D1F2-4563-B684-AAF776E6B341}.Release|Any CPU.Build.0 = Release|Any CPU
+ {91834652-D1F2-4563-B684-AAF776E6B341}.Release|x64.ActiveCfg = Release|Any CPU
+ {91834652-D1F2-4563-B684-AAF776E6B341}.Release|x64.Build.0 = Release|Any CPU
+ {91834652-D1F2-4563-B684-AAF776E6B341}.Release|x86.ActiveCfg = Release|Any CPU
+ {91834652-D1F2-4563-B684-AAF776E6B341}.Release|x86.Build.0 = Release|Any CPU
{A45980D7-8E11-4DF2-9802-D6E5E7056CD1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A45980D7-8E11-4DF2-9802-D6E5E7056CD1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A45980D7-8E11-4DF2-9802-D6E5E7056CD1}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {A45980D7-8E11-4DF2-9802-D6E5E7056CD1}.Debug|x64.Build.0 = Debug|Any CPU
+ {A45980D7-8E11-4DF2-9802-D6E5E7056CD1}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {A45980D7-8E11-4DF2-9802-D6E5E7056CD1}.Debug|x86.Build.0 = Debug|Any CPU
{A45980D7-8E11-4DF2-9802-D6E5E7056CD1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A45980D7-8E11-4DF2-9802-D6E5E7056CD1}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A45980D7-8E11-4DF2-9802-D6E5E7056CD1}.Release|x64.ActiveCfg = Release|Any CPU
+ {A45980D7-8E11-4DF2-9802-D6E5E7056CD1}.Release|x64.Build.0 = Release|Any CPU
+ {A45980D7-8E11-4DF2-9802-D6E5E7056CD1}.Release|x86.ActiveCfg = Release|Any CPU
+ {A45980D7-8E11-4DF2-9802-D6E5E7056CD1}.Release|x86.Build.0 = Release|Any CPU
{FF9E9629-FB10-4D56-B8B1-AA65748FECA7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FF9E9629-FB10-4D56-B8B1-AA65748FECA7}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {FF9E9629-FB10-4D56-B8B1-AA65748FECA7}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {FF9E9629-FB10-4D56-B8B1-AA65748FECA7}.Debug|x64.Build.0 = Debug|Any CPU
+ {FF9E9629-FB10-4D56-B8B1-AA65748FECA7}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {FF9E9629-FB10-4D56-B8B1-AA65748FECA7}.Debug|x86.Build.0 = Debug|Any CPU
{FF9E9629-FB10-4D56-B8B1-AA65748FECA7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FF9E9629-FB10-4D56-B8B1-AA65748FECA7}.Release|Any CPU.Build.0 = Release|Any CPU
+ {FF9E9629-FB10-4D56-B8B1-AA65748FECA7}.Release|x64.ActiveCfg = Release|Any CPU
+ {FF9E9629-FB10-4D56-B8B1-AA65748FECA7}.Release|x64.Build.0 = Release|Any CPU
+ {FF9E9629-FB10-4D56-B8B1-AA65748FECA7}.Release|x86.ActiveCfg = Release|Any CPU
+ {FF9E9629-FB10-4D56-B8B1-AA65748FECA7}.Release|x86.Build.0 = Release|Any CPU
{3181E206-1B81-4D8C-BFF2-9125A39D99F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3181E206-1B81-4D8C-BFF2-9125A39D99F5}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {3181E206-1B81-4D8C-BFF2-9125A39D99F5}.Debug|x64.Build.0 = Debug|Any CPU
+ {3181E206-1B81-4D8C-BFF2-9125A39D99F5}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {3181E206-1B81-4D8C-BFF2-9125A39D99F5}.Debug|x86.Build.0 = Debug|Any CPU
{3181E206-1B81-4D8C-BFF2-9125A39D99F5}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3181E206-1B81-4D8C-BFF2-9125A39D99F5}.Release|x64.ActiveCfg = Release|Any CPU
+ {3181E206-1B81-4D8C-BFF2-9125A39D99F5}.Release|x64.Build.0 = Release|Any CPU
+ {3181E206-1B81-4D8C-BFF2-9125A39D99F5}.Release|x86.ActiveCfg = Release|Any CPU
+ {3181E206-1B81-4D8C-BFF2-9125A39D99F5}.Release|x86.Build.0 = Release|Any CPU
{A6E76585-17FA-4F7F-A8EE-C4C517C0ABEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A6E76585-17FA-4F7F-A8EE-C4C517C0ABEE}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A6E76585-17FA-4F7F-A8EE-C4C517C0ABEE}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {A6E76585-17FA-4F7F-A8EE-C4C517C0ABEE}.Debug|x64.Build.0 = Debug|Any CPU
+ {A6E76585-17FA-4F7F-A8EE-C4C517C0ABEE}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {A6E76585-17FA-4F7F-A8EE-C4C517C0ABEE}.Debug|x86.Build.0 = Debug|Any CPU
{A6E76585-17FA-4F7F-A8EE-C4C517C0ABEE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A6E76585-17FA-4F7F-A8EE-C4C517C0ABEE}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A6E76585-17FA-4F7F-A8EE-C4C517C0ABEE}.Release|x64.ActiveCfg = Release|Any CPU
+ {A6E76585-17FA-4F7F-A8EE-C4C517C0ABEE}.Release|x64.Build.0 = Release|Any CPU
+ {A6E76585-17FA-4F7F-A8EE-C4C517C0ABEE}.Release|x86.ActiveCfg = Release|Any CPU
+ {A6E76585-17FA-4F7F-A8EE-C4C517C0ABEE}.Release|x86.Build.0 = Release|Any CPU
{822ECD72-5DB0-4637-B794-CE27B02827AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{822ECD72-5DB0-4637-B794-CE27B02827AC}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {822ECD72-5DB0-4637-B794-CE27B02827AC}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {822ECD72-5DB0-4637-B794-CE27B02827AC}.Debug|x64.Build.0 = Debug|Any CPU
+ {822ECD72-5DB0-4637-B794-CE27B02827AC}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {822ECD72-5DB0-4637-B794-CE27B02827AC}.Debug|x86.Build.0 = Debug|Any CPU
{822ECD72-5DB0-4637-B794-CE27B02827AC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{822ECD72-5DB0-4637-B794-CE27B02827AC}.Release|Any CPU.Build.0 = Release|Any CPU
+ {822ECD72-5DB0-4637-B794-CE27B02827AC}.Release|x64.ActiveCfg = Release|Any CPU
+ {822ECD72-5DB0-4637-B794-CE27B02827AC}.Release|x64.Build.0 = Release|Any CPU
+ {822ECD72-5DB0-4637-B794-CE27B02827AC}.Release|x86.ActiveCfg = Release|Any CPU
+ {822ECD72-5DB0-4637-B794-CE27B02827AC}.Release|x86.Build.0 = Release|Any CPU
+ {E3F3D28A-919C-4283-AEBD-A915A3EFE047}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E3F3D28A-919C-4283-AEBD-A915A3EFE047}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E3F3D28A-919C-4283-AEBD-A915A3EFE047}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {E3F3D28A-919C-4283-AEBD-A915A3EFE047}.Debug|x64.Build.0 = Debug|Any CPU
+ {E3F3D28A-919C-4283-AEBD-A915A3EFE047}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {E3F3D28A-919C-4283-AEBD-A915A3EFE047}.Debug|x86.Build.0 = Debug|Any CPU
+ {E3F3D28A-919C-4283-AEBD-A915A3EFE047}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E3F3D28A-919C-4283-AEBD-A915A3EFE047}.Release|Any CPU.Build.0 = Release|Any CPU
+ {E3F3D28A-919C-4283-AEBD-A915A3EFE047}.Release|x64.ActiveCfg = Release|Any CPU
+ {E3F3D28A-919C-4283-AEBD-A915A3EFE047}.Release|x64.Build.0 = Release|Any CPU
+ {E3F3D28A-919C-4283-AEBD-A915A3EFE047}.Release|x86.ActiveCfg = Release|Any CPU
+ {E3F3D28A-919C-4283-AEBD-A915A3EFE047}.Release|x86.Build.0 = Release|Any CPU
+ {5614CD87-E146-4D66-A199-5F110B2A0445}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {5614CD87-E146-4D66-A199-5F110B2A0445}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {5614CD87-E146-4D66-A199-5F110B2A0445}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {5614CD87-E146-4D66-A199-5F110B2A0445}.Debug|x64.Build.0 = Debug|Any CPU
+ {5614CD87-E146-4D66-A199-5F110B2A0445}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {5614CD87-E146-4D66-A199-5F110B2A0445}.Debug|x86.Build.0 = Debug|Any CPU
+ {5614CD87-E146-4D66-A199-5F110B2A0445}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {5614CD87-E146-4D66-A199-5F110B2A0445}.Release|Any CPU.Build.0 = Release|Any CPU
+ {5614CD87-E146-4D66-A199-5F110B2A0445}.Release|x64.ActiveCfg = Release|Any CPU
+ {5614CD87-E146-4D66-A199-5F110B2A0445}.Release|x64.Build.0 = Release|Any CPU
+ {5614CD87-E146-4D66-A199-5F110B2A0445}.Release|x86.ActiveCfg = Release|Any CPU
+ {5614CD87-E146-4D66-A199-5F110B2A0445}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
+ {33773A69-F53A-4BC8-B3B9-564C8C9B0E23} = {7DBE5A9D-0500-4C35-A673-481C411B0FA1}
+ {C8E5E8C0-FAA5-49D3-B121-F5E5BBF7C0EF} = {7DBE5A9D-0500-4C35-A673-481C411B0FA1}
+ {13FED5D5-41CE-4DB5-A0FD-5A1EB5FBEF51} = {7DBE5A9D-0500-4C35-A673-481C411B0FA1}
{B79F3B3E-C9CE-4629-ADE3-B1659AF9C673} = {13FED5D5-41CE-4DB5-A0FD-5A1EB5FBEF51}
{5F02B2B8-FC8C-44D8-A0CD-E9E31115EAB5} = {B79F3B3E-C9CE-4629-ADE3-B1659AF9C673}
- {15C3A03E-F2A5-4497-B0DE-A9EB73C9E6AF} = {0CBB6422-3CF8-4014-B881-E48DBC964D99}
- {13FED5D5-41CE-4DB5-A0FD-5A1EB5FBEF51} = {7DBE5A9D-0500-4C35-A673-481C411B0FA1}
- {33773A69-F53A-4BC8-B3B9-564C8C9B0E23} = {7DBE5A9D-0500-4C35-A673-481C411B0FA1}
{1DB2232F-17CF-40AF-9190-09441C860BEB} = {7DBE5A9D-0500-4C35-A673-481C411B0FA1}
- {C8E5E8C0-FAA5-49D3-B121-F5E5BBF7C0EF} = {7DBE5A9D-0500-4C35-A673-481C411B0FA1}
+ {15C3A03E-F2A5-4497-B0DE-A9EB73C9E6AF} = {0CBB6422-3CF8-4014-B881-E48DBC964D99}
{91834652-D1F2-4563-B684-AAF776E6B341} = {5BD797BA-4D66-4F55-A55E-5F1063678D8B}
{A45980D7-8E11-4DF2-9802-D6E5E7056CD1} = {5BD797BA-4D66-4F55-A55E-5F1063678D8B}
{FF9E9629-FB10-4D56-B8B1-AA65748FECA7} = {7DBE5A9D-0500-4C35-A673-481C411B0FA1}
@@ -148,6 +268,8 @@ Global
{386F9478-8C54-4B48-A6C3-9F3D009A799C} = {73022F12-1D56-44FF-AFF1-9FEA43637836}
{A6E76585-17FA-4F7F-A8EE-C4C517C0ABEE} = {B79F3B3E-C9CE-4629-ADE3-B1659AF9C673}
{822ECD72-5DB0-4637-B794-CE27B02827AC} = {B79F3B3E-C9CE-4629-ADE3-B1659AF9C673}
+ {E3F3D28A-919C-4283-AEBD-A915A3EFE047} = {5BD797BA-4D66-4F55-A55E-5F1063678D8B}
+ {5614CD87-E146-4D66-A199-5F110B2A0445} = {5BD797BA-4D66-4F55-A55E-5F1063678D8B}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {3E5E58EB-0096-4ED2-B1DE-D7FC5951CAB7}
diff --git a/source/CreativeCoders.HomeMatic.Core/CompleteCcuDeviceBuildOptions.cs b/source/CreativeCoders.HomeMatic.Core/CompleteCcuDeviceBuildOptions.cs
new file mode 100644
index 0000000..055c5f0
--- /dev/null
+++ b/source/CreativeCoders.HomeMatic.Core/CompleteCcuDeviceBuildOptions.cs
@@ -0,0 +1,24 @@
+using CreativeCoders.HomeMatic.XmlRpc.Links;
+using JetBrains.Annotations;
+
+namespace CreativeCoders.HomeMatic.Core;
+
+///
+/// Optional configuration for building an snapshot.
+///
+[PublicAPI]
+public class CompleteCcuDeviceBuildOptions
+{
+ ///
+ /// Gets or sets a value indicating whether the communication links of each channel are fetched
+ /// from the CCU and stored in the snapshot.
+ ///
+ /// to include links; otherwise, . Default is .
+ public bool IncludeLinks { get; set; }
+
+ ///
+ /// Gets or sets the flags forwarded to getLinks when is enabled.
+ ///
+ /// The value. Default is .
+ public GetLinksFlags LinksFlags { get; set; } = GetLinksFlags.None;
+}
diff --git a/source/CreativeCoders.HomeMatic.Core/Devices/ICcuDeviceChannel.cs b/source/CreativeCoders.HomeMatic.Core/Devices/ICcuDeviceChannel.cs
index 98c95dd..26e8b24 100644
--- a/source/CreativeCoders.HomeMatic.Core/Devices/ICcuDeviceChannel.cs
+++ b/source/CreativeCoders.HomeMatic.Core/Devices/ICcuDeviceChannel.cs
@@ -1,6 +1,65 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using CreativeCoders.HomeMatic.XmlRpc.Links;
+
namespace CreativeCoders.HomeMatic.Core.Devices;
///
/// Represents a single channel of a HomeMatic device.
///
-public interface ICcuDeviceChannel : ICcuDeviceBase, ICcuDeviceChannelData;
+public interface ICcuDeviceChannel : ICcuDeviceBase, ICcuDeviceChannelData
+{
+ ///
+ /// Asynchronously retrieves all communication links assigned to this channel.
+ ///
+ /// A bitwise combination of values controlling the level of detail.
+ /// A task that yields a collection of structures describing each link.
+ Task> GetLinksAsync(GetLinksFlags flags = GetLinksFlags.None);
+
+ ///
+ /// Asynchronously retrieves the addresses of all communication partners of this channel.
+ ///
+ /// A task that yields the peer addresses.
+ Task> GetLinkPeersAsync();
+
+ ///
+ /// Asynchronously creates a communication link from this channel to the specified receiver.
+ ///
+ /// The address of the receiver of the link.
+ /// An optional name for the link.
+ /// An optional description for the link.
+ /// A task that completes when the link has been created.
+ Task AddLinkToAsync(string receiverAddress, string name = "", string description = "");
+
+ ///
+ /// Asynchronously removes the communication link from this channel to the specified receiver.
+ ///
+ /// The address of the receiver of the link.
+ /// A task that completes when the link has been removed.
+ Task RemoveLinkToAsync(string receiverAddress);
+
+ ///
+ /// Asynchronously updates the descriptive texts of an existing communication link from this channel.
+ ///
+ /// The address of the receiver of the link.
+ /// The new name of the link.
+ /// The new description of the link.
+ /// A task that completes when the link has been updated.
+ Task SetLinkInfoAsync(string receiverAddress, string name, string description);
+
+ ///
+ /// Asynchronously retrieves the descriptive information of an existing communication link from this channel.
+ ///
+ /// The address of the receiver of the link.
+ /// A task that yields a instance.
+ Task GetLinkInfoAsync(string receiverAddress);
+
+ ///
+ /// Asynchronously activates a link parameter set so that this channel behaves as if it had been
+ /// triggered directly by the specified communication partner.
+ ///
+ /// The address of the communication partner whose link parameter set is activated.
+ /// to activate the parameter set for a long key press; otherwise .
+ /// A task that completes when the parameter set has been activated.
+ Task ActivateLinkParamsetAsync(string peerAddress, bool longPress);
+}
diff --git a/source/CreativeCoders.HomeMatic.Core/Devices/ICompleteCcuDeviceChannel.cs b/source/CreativeCoders.HomeMatic.Core/Devices/ICompleteCcuDeviceChannel.cs
index 685703c..11dc8e7 100644
--- a/source/CreativeCoders.HomeMatic.Core/Devices/ICompleteCcuDeviceChannel.cs
+++ b/source/CreativeCoders.HomeMatic.Core/Devices/ICompleteCcuDeviceChannel.cs
@@ -1,4 +1,5 @@
using System.Collections.Generic;
+using CreativeCoders.HomeMatic.XmlRpc.Links;
namespace CreativeCoders.HomeMatic.Core.Devices;
@@ -8,14 +9,23 @@ namespace CreativeCoders.HomeMatic.Core.Devices;
public interface ICompleteCcuDeviceChannel
{
///
- /// Gets the channel-level data.
+ /// Gets the channel and its operations.
///
- /// The for this channel.
- ICcuDeviceChannelData ChannelData { get; }
+ /// The for this channel.
+ ICcuDeviceChannel ChannelData { get; }
///
/// Gets the parameter-set values and descriptions for the channel.
///
/// The enumerable of groups.
IEnumerable ParamSetValues { get; }
+
+ ///
+ /// Gets the communication links of the channel that were fetched during snapshot creation.
+ ///
+ ///
+ /// The collection of structures. Empty when the snapshot was built without
+ /// link fetching enabled.
+ ///
+ IEnumerable Links { get; }
}
diff --git a/source/CreativeCoders.HomeMatic.Core/ICcuClient.cs b/source/CreativeCoders.HomeMatic.Core/ICcuClient.cs
index 3ea57e4..5ac5dbf 100644
--- a/source/CreativeCoders.HomeMatic.Core/ICcuClient.cs
+++ b/source/CreativeCoders.HomeMatic.Core/ICcuClient.cs
@@ -1,6 +1,8 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using CreativeCoders.HomeMatic.Core.Devices;
+using CreativeCoders.HomeMatic.XmlRpc;
+using CreativeCoders.HomeMatic.XmlRpc.Links;
namespace CreativeCoders.HomeMatic.Core;
@@ -25,13 +27,71 @@ public interface ICcuClient
///
/// Asynchronously retrieves all devices including their parameter descriptions.
///
+ /// Optional build options controlling whether links are fetched.
/// A task that yields an enumerable of instances.
- Task> GetCompleteDevicesAsync();
+ Task> GetCompleteDevicesAsync(
+ CompleteCcuDeviceBuildOptions? buildOptions = null);
///
/// Asynchronously retrieves a single device including its parameter descriptions.
///
/// The device address.
+ /// Optional build options controlling whether links are fetched.
/// A task that yields the matching .
- Task GetCompleteDeviceAsync(string address);
+ Task GetCompleteDeviceAsync(string address,
+ CompleteCcuDeviceBuildOptions? buildOptions = null);
+
+ ///
+ /// Asynchronously retrieves all communication links known to the CCU interface process of the
+ /// specified device kind.
+ ///
+ /// The device kind whose interface process is queried.
+ /// A bitwise combination of values controlling the level of detail.
+ /// A task that yields a collection of structures describing each link.
+ Task> GetAllLinksAsync(CcuDeviceKind kind = CcuDeviceKind.HomeMatic,
+ GetLinksFlags flags = GetLinksFlags.None);
+
+ ///
+ /// Asynchronously creates a communication link between two logical channels or devices.
+ ///
+ /// The address of the sender of the link.
+ /// The address of the receiver of the link.
+ /// An optional name for the link.
+ /// An optional description for the link.
+ /// The device kind whose interface process performs the operation.
+ /// A task that completes when the link has been created.
+ Task AddLinkAsync(string senderAddress, string receiverAddress, string name = "",
+ string description = "", CcuDeviceKind kind = CcuDeviceKind.HomeMatic);
+
+ ///
+ /// Asynchronously removes the communication link between two logical channels or devices.
+ ///
+ /// The address of the sender of the link.
+ /// The address of the receiver of the link.
+ /// The device kind whose interface process performs the operation.
+ /// A task that completes when the link has been removed.
+ Task RemoveLinkAsync(string senderAddress, string receiverAddress,
+ CcuDeviceKind kind = CcuDeviceKind.HomeMatic);
+
+ ///
+ /// Asynchronously updates the descriptive texts of an existing communication link.
+ ///
+ /// The address of the sender of the link.
+ /// The address of the receiver of the link.
+ /// The new name of the link.
+ /// The new description of the link.
+ /// The device kind whose interface process performs the operation.
+ /// A task that completes when the link has been updated.
+ Task SetLinkInfoAsync(string senderAddress, string receiverAddress, string name,
+ string description, CcuDeviceKind kind = CcuDeviceKind.HomeMatic);
+
+ ///
+ /// Asynchronously retrieves the descriptive information of an existing communication link.
+ ///
+ /// The address of the sender of the link.
+ /// The address of the receiver of the link.
+ /// The device kind whose interface process performs the operation.
+ /// A task that yields a instance.
+ Task GetLinkInfoAsync(string senderAddress, string receiverAddress,
+ CcuDeviceKind kind = CcuDeviceKind.HomeMatic);
}
diff --git a/source/CreativeCoders.HomeMatic.Core/ICompleteCcuDeviceBuilder.cs b/source/CreativeCoders.HomeMatic.Core/ICompleteCcuDeviceBuilder.cs
index 9fca400..0545eac 100644
--- a/source/CreativeCoders.HomeMatic.Core/ICompleteCcuDeviceBuilder.cs
+++ b/source/CreativeCoders.HomeMatic.Core/ICompleteCcuDeviceBuilder.cs
@@ -12,6 +12,7 @@ public interface ICompleteCcuDeviceBuilder
/// Asynchronously builds a complete device representation for the specified device.
///
/// The base device to augment with parameter descriptions.
+ /// Optional build options controlling whether links are fetched.
/// A task that yields the completed .
- Task BuildAsync(ICcuDevice device);
+ Task BuildAsync(ICcuDevice device, CompleteCcuDeviceBuildOptions? options = null);
}
diff --git a/source/CreativeCoders.HomeMatic.Core/IMultiCcuClient.cs b/source/CreativeCoders.HomeMatic.Core/IMultiCcuClient.cs
index f211367..8832986 100644
--- a/source/CreativeCoders.HomeMatic.Core/IMultiCcuClient.cs
+++ b/source/CreativeCoders.HomeMatic.Core/IMultiCcuClient.cs
@@ -27,13 +27,17 @@ public interface IMultiCcuClient
///
/// Asynchronously retrieves all devices including their parameter descriptions from every configured CCU.
///
+ /// Optional build options controlling whether links are fetched.
/// A task that yields an enumerable of instances.
- Task> GetCompleteDevicesAsync();
+ Task> GetCompleteDevicesAsync(
+ CompleteCcuDeviceBuildOptions? buildOptions = null);
///
/// Asynchronously retrieves a single device including its parameter descriptions across all configured CCUs.
///
/// The device address.
+ /// Optional build options controlling whether links are fetched.
/// A task that yields the matching .
- Task GetCompleteDeviceAsync(string address);
+ Task GetCompleteDeviceAsync(string address,
+ CompleteCcuDeviceBuildOptions? buildOptions = null);
}
diff --git a/source/CreativeCoders.HomeMatic.XmlRpc/Client/HomeMaticXmlRpcApiBuilder.cs b/source/CreativeCoders.HomeMatic.XmlRpc/Client/HomeMaticXmlRpcApiBuilder.cs
index 4bd7729..ee59697 100644
--- a/source/CreativeCoders.HomeMatic.XmlRpc/Client/HomeMaticXmlRpcApiBuilder.cs
+++ b/source/CreativeCoders.HomeMatic.XmlRpc/Client/HomeMaticXmlRpcApiBuilder.cs
@@ -1,4 +1,5 @@
using System;
+using System.Text;
using CreativeCoders.Core;
using CreativeCoders.Net.XmlRpc.Proxy;
using JetBrains.Annotations;
@@ -50,6 +51,7 @@ public IHomeMaticXmlRpcApi Build()
}
return _proxyBuilder
+ .UseEncoding(Encoding.Latin1)
.ForUrl(_url)
.Build();
}
diff --git a/source/CreativeCoders.HomeMatic.XmlRpc/Client/HomeMaticXmlRpcApiLinkExtensions.cs b/source/CreativeCoders.HomeMatic.XmlRpc/Client/HomeMaticXmlRpcApiLinkExtensions.cs
new file mode 100644
index 0000000..d23ef33
--- /dev/null
+++ b/source/CreativeCoders.HomeMatic.XmlRpc/Client/HomeMaticXmlRpcApiLinkExtensions.cs
@@ -0,0 +1,65 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using CreativeCoders.Core;
+using CreativeCoders.HomeMatic.XmlRpc.Links;
+using JetBrains.Annotations;
+
+namespace CreativeCoders.HomeMatic.XmlRpc.Client;
+
+///
+/// Provides strongly-typed convenience overloads for the link-related methods of
+/// .
+///
+[PublicAPI]
+public static class HomeMaticXmlRpcApiLinkExtensions
+{
+ ///
+ /// Retrieves all communication links assigned to a logical device or channel using a
+ /// strongly-typed argument.
+ ///
+ /// The API instance to invoke.
+ ///
+ /// The channel or device address. Pass an empty string to retrieve all links of the entire
+ /// interface process.
+ ///
+ /// A bitwise combination of values.
+ /// A collection of structures describing each link.
+ public static Task> GetLinksAsync(this IHomeMaticXmlRpcApi api, string address,
+ GetLinksFlags flags = GetLinksFlags.None)
+ {
+ Ensure.NotNull(api);
+ Ensure.NotNull(address);
+
+ return api.GetLinksAsync(address, (int) flags);
+ }
+
+ ///
+ /// Retrieves the descriptive information of an existing communication link as a
+ /// strongly-typed instance.
+ ///
+ /// The API instance to invoke.
+ /// The address of the sender of the link.
+ /// The address of the receiver of the link.
+ ///
+ /// A instance whose and
+ /// are populated from the XML-RPC response. If the response
+ /// contains fewer than two entries, the missing fields default to an empty string.
+ ///
+ public static async Task GetLinkInfoAsync(this IHomeMaticXmlRpcApi api,
+ string senderAddress, string receiverAddress)
+ {
+ Ensure.NotNull(api);
+ Ensure.NotNull(senderAddress);
+ Ensure.NotNull(receiverAddress);
+
+ var raw = (await api.GetLinkInfoRawAsync(senderAddress, receiverAddress).ConfigureAwait(false))
+ ?.ToArray() ?? [];
+
+ return new LinkInfo
+ {
+ Name = raw.Length > 0 ? raw[0] ?? string.Empty : string.Empty,
+ Description = raw.Length > 1 ? raw[1] ?? string.Empty : string.Empty
+ };
+ }
+}
diff --git a/source/CreativeCoders.HomeMatic.XmlRpc/Client/IHomeMaticXmlRpcApi.cs b/source/CreativeCoders.HomeMatic.XmlRpc/Client/IHomeMaticXmlRpcApi.cs
index 0c9bb3d..cc36169 100644
--- a/source/CreativeCoders.HomeMatic.XmlRpc/Client/IHomeMaticXmlRpcApi.cs
+++ b/source/CreativeCoders.HomeMatic.XmlRpc/Client/IHomeMaticXmlRpcApi.cs
@@ -1,5 +1,7 @@
using System.Collections.Generic;
+using System.ComponentModel;
using System.Threading.Tasks;
+using CreativeCoders.HomeMatic.XmlRpc.Links;
using CreativeCoders.Net.XmlRpc.Definition;
using JetBrains.Annotations;
@@ -170,4 +172,100 @@ public interface IHomeMaticXmlRpcApi
/// The version string of the interface process.
[XmlRpcMethod("getVersion")]
Task GetVersionAsync();
+
+ ///
+ /// Retrieves all communication links assigned to a logical device or channel.
+ ///
+ ///
+ /// The channel or device address. Pass an empty string to retrieve all links of the entire
+ /// interface process.
+ ///
+ ///
+ /// A bitwise combination of values cast to . See
+ ///
+ /// for a strongly-typed overload.
+ ///
+ /// A collection of structures describing each link.
+ /// See section 4.2.10 of the HomeMatic XML-RPC specification.
+ [XmlRpcMethod("getLinks")]
+ Task> GetLinksAsync(string address, int flags);
+
+ ///
+ /// Creates a communication link between two logical devices or channels.
+ ///
+ /// The address of the sender of the link.
+ /// The address of the receiver of the link.
+ /// An optional name for the link. Pass an empty string when not used.
+ /// An optional description for the link. Pass an empty string when not used.
+ /// See section 4.2.11 of the HomeMatic XML-RPC specification.
+ [XmlRpcMethod("addLink")]
+ Task AddLinkAsync(string sender, string receiver, string name, string description);
+
+ ///
+ /// Removes the communication link between two logical devices or channels.
+ ///
+ /// The address of the sender of the link.
+ /// The address of the receiver of the link.
+ /// See section 4.2.12 of the HomeMatic XML-RPC specification.
+ [XmlRpcMethod("removeLink")]
+ Task RemoveLinkAsync(string sender, string receiver);
+
+ ///
+ /// Updates the descriptive texts of an existing communication link.
+ ///
+ /// The address of the sender of the link.
+ /// The address of the receiver of the link.
+ /// The new name of the link.
+ /// The new description of the link.
+ ///
+ /// See section 4.3.1 of the HomeMatic XML-RPC specification. This method is supported by
+ /// BidCoS-RF and BidCoS-Wired interface processes.
+ ///
+ [XmlRpcMethod("setLinkInfo")]
+ Task SetLinkInfoAsync(string sender, string receiver, string name, string description);
+
+ ///
+ /// Retrieves the raw [name, description] tuple of an existing communication link as
+ /// returned by the XML-RPC interface.
+ ///
+ /// The address of the sender of the link.
+ /// The address of the receiver of the link.
+ /// A two-element string sequence: the link name followed by the link description.
+ ///
+ /// See section 4.3.2 of the HomeMatic XML-RPC specification. Prefer the strongly-typed
+ /// extension method
+ ///
+ /// which returns a instance.
+ ///
+ [EditorBrowsable(EditorBrowsableState.Advanced)]
+ [XmlRpcMethod("getLinkInfo")]
+ Task> GetLinkInfoRawAsync(string senderAddress, string receiverAddress);
+
+ ///
+ /// Activates a link parameter set so that the logical device behaves as if it had been
+ /// triggered directly by the assigned communication partner.
+ ///
+ /// The address of the logical device whose link parameter set should be activated.
+ /// The address of the communication partner whose link parameter set is activated.
+ ///
+ /// to activate the parameter set for a long key press; otherwise .
+ ///
+ ///
+ /// See section 4.3.3 of the HomeMatic XML-RPC specification. This method is supported by
+ /// BidCoS-RF interface processes only.
+ ///
+ [XmlRpcMethod("activateLinkParamset")]
+ Task ActivateLinkParamsetAsync(string address, string peerAddress, bool longPress);
+
+ ///
+ /// Retrieves all communication partners assigned to a logical device.
+ ///
+ /// The address of the logical device.
+ ///
+ /// A collection of peer addresses. Each entry can be used as the paramSetKey argument
+ /// of and .
+ ///
+ /// See section 4.3.20 of the HomeMatic XML-RPC specification.
+ [XmlRpcMethod("getLinkPeers")]
+ Task> GetLinkPeersAsync(string address);
}
\ No newline at end of file
diff --git a/source/CreativeCoders.HomeMatic.XmlRpc/Converters/ParamSetDictionaryValueConverter.cs b/source/CreativeCoders.HomeMatic.XmlRpc/Converters/ParamSetDictionaryValueConverter.cs
new file mode 100644
index 0000000..e0fe437
--- /dev/null
+++ b/source/CreativeCoders.HomeMatic.XmlRpc/Converters/ParamSetDictionaryValueConverter.cs
@@ -0,0 +1,55 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using CreativeCoders.Net.XmlRpc.Definition;
+using CreativeCoders.Net.XmlRpc.Model;
+using CreativeCoders.Net.XmlRpc.Model.Values;
+
+namespace CreativeCoders.HomeMatic.XmlRpc.Converters;
+
+///
+/// Converts an XML-RPC value representing a parameter set into a
+/// with string keys and object values.
+///
+///
+/// HomeMatic / homegear CCUs occasionally return the SENDER_PARAMSET and
+/// RECEIVER_PARAMSET members of a getLinks entry as an empty
+/// instead of an empty when the
+/// corresponding paramset flag was not requested. The default
+/// implementation rejects this with an
+/// . This converter tolerates the deviation
+/// and returns an empty dictionary for any non-struct value.
+///
+public class ParamSetDictionaryValueConverter : IXmlRpcMemberValueConverter
+{
+ ///
+ /// Converts an into a .
+ ///
+ /// The XML-RPC value to convert.
+ ///
+ /// A dictionary mapping member names to their underlying data when
+ /// is a ; an empty dictionary
+ /// otherwise.
+ ///
+ public object ConvertFromValue(XmlRpcValue xmlRpcValue)
+ {
+ if (xmlRpcValue is StructValue structValue)
+ {
+ return structValue.Value
+ .ToDictionary(member => member.Key, member => member.Value.Data);
+ }
+
+ return new Dictionary();
+ }
+
+ ///
+ /// Converts a dictionary value into an .
+ ///
+ /// The value to convert.
+ /// This method is not implemented and always throws .
+ /// Always thrown; serialization of paramset dictionaries is not supported.
+ public XmlRpcValue ConvertFromObject(object value)
+ {
+ throw new NotImplementedException();
+ }
+}
diff --git a/source/CreativeCoders.HomeMatic.XmlRpc/Links/GetLinksFlags.cs b/source/CreativeCoders.HomeMatic.XmlRpc/Links/GetLinksFlags.cs
new file mode 100644
index 0000000..47e9469
--- /dev/null
+++ b/source/CreativeCoders.HomeMatic.XmlRpc/Links/GetLinksFlags.cs
@@ -0,0 +1,39 @@
+using System;
+using JetBrains.Annotations;
+
+namespace CreativeCoders.HomeMatic.XmlRpc.Links;
+
+///
+/// Specifies the option flags for the getLinks XML-RPC method.
+///
+///
+/// Values can be combined with bitwise OR. See section 4.2.10 of the HomeMatic XML-RPC
+/// specification for details.
+///
+[PublicAPI]
+[Flags]
+public enum GetLinksFlags
+{
+ ///
+ /// No optional fields are requested. The default behaviour.
+ ///
+ None = 0,
+
+ ///
+ /// Returns the links of all channels in the same group when the address denotes a grouped channel
+ /// (GL_FLAG_GROUP).
+ ///
+ Group = 1,
+
+ ///
+ /// Includes the SENDER_PARAMSET field in the returned structures
+ /// (GL_FLAG_SENDER_PARAMSET).
+ ///
+ SenderParamSet = 2,
+
+ ///
+ /// Includes the RECEIVER_PARAMSET field in the returned structures
+ /// (GL_FLAG_RECEIVER_PARAMSET).
+ ///
+ ReceiverParamSet = 4
+}
diff --git a/source/CreativeCoders.HomeMatic.XmlRpc/Links/Link.cs b/source/CreativeCoders.HomeMatic.XmlRpc/Links/Link.cs
new file mode 100644
index 0000000..88b8e15
--- /dev/null
+++ b/source/CreativeCoders.HomeMatic.XmlRpc/Links/Link.cs
@@ -0,0 +1,78 @@
+using System.Collections.Generic;
+using CreativeCoders.HomeMatic.XmlRpc.Converters;
+using CreativeCoders.Net.XmlRpc.Definition;
+using JetBrains.Annotations;
+
+namespace CreativeCoders.HomeMatic.XmlRpc.Links;
+
+///
+/// Describes a HomeMatic communication link between two logical devices or channels as returned
+/// by the getLinks XML-RPC method.
+///
+///
+/// See section 4.2.10 of the HomeMatic XML-RPC specification. The
+/// and fields are only populated when the corresponding
+/// or flag
+/// is passed to getLinks; otherwise they default to an empty dictionary.
+///
+[PublicAPI]
+public class Link
+{
+ ///
+ /// Gets or sets the address of the sender of this communication link.
+ ///
+ /// The channel or device address of the sender (e.g. ABC1234567:1).
+ [XmlRpcStructMember("SENDER", Required = true)]
+ public string Sender { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the address of the receiver of this communication link.
+ ///
+ /// The channel or device address of the receiver (e.g. ABC1234567:2).
+ [XmlRpcStructMember("RECEIVER", Required = true)]
+ public string Receiver { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the state flags of this communication link.
+ ///
+ /// A bitwise combination of values.
+ [XmlRpcStructMember("FLAGS", DefaultValue = LinkFlags.None,
+ Converter = typeof(FlagsMemberValueConverter))]
+ public LinkFlags Flags { get; set; }
+
+ ///
+ /// Gets or sets the human-readable name of this communication link.
+ ///
+ /// The link name; an empty string if not set.
+ [XmlRpcStructMember("NAME", DefaultValue = "")]
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the textual description of this communication link.
+ ///
+ /// The link description; an empty string if not set.
+ [XmlRpcStructMember("DESCRIPTION", DefaultValue = "")]
+ public string Description { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the parameter set associated with the sender side of this communication link.
+ ///
+ ///
+ /// A dictionary mapping parameter names to their current values. Only populated when the
+ /// flag was passed to getLinks; otherwise
+ /// an empty dictionary.
+ ///
+ [XmlRpcStructMember("SENDER_PARAMSET", Converter = typeof(ParamSetDictionaryValueConverter))]
+ public Dictionary SenderParamSet { get; set; } = new();
+
+ ///
+ /// Gets or sets the parameter set associated with the receiver side of this communication link.
+ ///
+ ///
+ /// A dictionary mapping parameter names to their current values. Only populated when the
+ /// flag was passed to getLinks; otherwise
+ /// an empty dictionary.
+ ///
+ [XmlRpcStructMember("RECEIVER_PARAMSET", Converter = typeof(ParamSetDictionaryValueConverter))]
+ public Dictionary ReceiverParamSet { get; set; } = new();
+}
diff --git a/source/CreativeCoders.HomeMatic.XmlRpc/Links/LinkFlags.cs b/source/CreativeCoders.HomeMatic.XmlRpc/Links/LinkFlags.cs
new file mode 100644
index 0000000..2a48ad5
--- /dev/null
+++ b/source/CreativeCoders.HomeMatic.XmlRpc/Links/LinkFlags.cs
@@ -0,0 +1,32 @@
+using System;
+using JetBrains.Annotations;
+
+namespace CreativeCoders.HomeMatic.XmlRpc.Links;
+
+///
+/// Specifies the state of a HomeMatic communication link as reported in the FLAGS
+/// field of a structure.
+///
+///
+/// Values can be combined with bitwise OR. See section 4.2.10 of the HomeMatic XML-RPC
+/// specification.
+///
+[PublicAPI]
+[Flags]
+public enum LinkFlags
+{
+ ///
+ /// The link is intact on both sides.
+ ///
+ None = 0,
+
+ ///
+ /// The link is broken on the sender side (LINK_FLAG_SENDER_BROKEN).
+ ///
+ SenderBroken = 1,
+
+ ///
+ /// The link is broken on the receiver side (LINK_FLAG_RECEIVER_BROKEN).
+ ///
+ ReceiverBroken = 2
+}
diff --git a/source/CreativeCoders.HomeMatic.XmlRpc/Links/LinkInfo.cs b/source/CreativeCoders.HomeMatic.XmlRpc/Links/LinkInfo.cs
new file mode 100644
index 0000000..3ef80f0
--- /dev/null
+++ b/source/CreativeCoders.HomeMatic.XmlRpc/Links/LinkInfo.cs
@@ -0,0 +1,27 @@
+using JetBrains.Annotations;
+
+namespace CreativeCoders.HomeMatic.XmlRpc.Links;
+
+///
+/// Represents the descriptive information of a HomeMatic communication link as returned by
+/// the getLinkInfo XML-RPC method.
+///
+///
+/// The XML-RPC specification (section 4.3.2) returns this information as a string array of the
+/// form [name, description]. This SDK exposes it as a dedicated type for clarity.
+///
+[PublicAPI]
+public class LinkInfo
+{
+ ///
+ /// Gets or sets the human-readable name of the communication link.
+ ///
+ /// The link name; an empty string if not set.
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the textual description of the communication link.
+ ///
+ /// The link description; an empty string if not set.
+ public string Description { get; set; } = string.Empty;
+}
diff --git a/source/CreativeCoders.HomeMatic/CcuClient.cs b/source/CreativeCoders.HomeMatic/CcuClient.cs
index ba478b1..62750c5 100644
--- a/source/CreativeCoders.HomeMatic/CcuClient.cs
+++ b/source/CreativeCoders.HomeMatic/CcuClient.cs
@@ -1,8 +1,11 @@
+using CreativeCoders.Core;
using CreativeCoders.Core.Collections;
using CreativeCoders.HomeMatic.Core;
using CreativeCoders.HomeMatic.Core.Devices;
using CreativeCoders.HomeMatic.JsonRpc;
using CreativeCoders.HomeMatic.XmlRpc;
+using CreativeCoders.HomeMatic.XmlRpc.Client;
+using CreativeCoders.HomeMatic.XmlRpc.Links;
namespace CreativeCoders.HomeMatic;
@@ -70,23 +73,87 @@ public async Task GetDeviceAsync(string address)
}
///
- public async Task> GetCompleteDevicesAsync()
+ public async Task> GetCompleteDevicesAsync(
+ CompleteCcuDeviceBuildOptions? buildOptions = null)
{
var completeDevices = new List();
foreach (var ccuDevice in await GetDevicesAsync().ConfigureAwait(false))
{
- completeDevices.Add(await completeCcuDeviceBuilder.BuildAsync(ccuDevice).ConfigureAwait(false));
+ completeDevices.Add(await completeCcuDeviceBuilder.BuildAsync(ccuDevice, buildOptions).ConfigureAwait(false));
}
return [..completeDevices];
}
///
- public async Task GetCompleteDeviceAsync(string address)
+ public async Task GetCompleteDeviceAsync(string address,
+ CompleteCcuDeviceBuildOptions? buildOptions = null)
{
var ccuDevice = await GetDeviceAsync(address).ConfigureAwait(false);
- return await completeCcuDeviceBuilder.BuildAsync(ccuDevice).ConfigureAwait(false);
+ return await completeCcuDeviceBuilder.BuildAsync(ccuDevice, buildOptions).ConfigureAwait(false);
+ }
+
+ ///
+ public Task> GetAllLinksAsync(CcuDeviceKind kind = CcuDeviceKind.HomeMatic,
+ GetLinksFlags flags = GetLinksFlags.None)
+ {
+ return GetApi(kind).GetLinksAsync(string.Empty, flags);
+ }
+
+ ///
+ public Task AddLinkAsync(string senderAddress, string receiverAddress, string name = "",
+ string description = "", CcuDeviceKind kind = CcuDeviceKind.HomeMatic)
+ {
+ Ensure.IsNotNullOrWhitespace(senderAddress);
+ Ensure.IsNotNullOrWhitespace(receiverAddress);
+ Ensure.NotNull(name);
+ Ensure.NotNull(description);
+
+ return GetApi(kind).AddLinkAsync(senderAddress, receiverAddress, name, description);
+ }
+
+ ///
+ public Task RemoveLinkAsync(string senderAddress, string receiverAddress,
+ CcuDeviceKind kind = CcuDeviceKind.HomeMatic)
+ {
+ Ensure.IsNotNullOrWhitespace(senderAddress);
+ Ensure.IsNotNullOrWhitespace(receiverAddress);
+
+ return GetApi(kind).RemoveLinkAsync(senderAddress, receiverAddress);
+ }
+
+ ///
+ public Task SetLinkInfoAsync(string senderAddress, string receiverAddress, string name,
+ string description, CcuDeviceKind kind = CcuDeviceKind.HomeMatic)
+ {
+ Ensure.IsNotNullOrWhitespace(senderAddress);
+ Ensure.IsNotNullOrWhitespace(receiverAddress);
+ Ensure.NotNull(name);
+ Ensure.NotNull(description);
+
+ return GetApi(kind).SetLinkInfoAsync(senderAddress, receiverAddress, name, description);
+ }
+
+ ///
+ public Task GetLinkInfoAsync(string senderAddress, string receiverAddress,
+ CcuDeviceKind kind = CcuDeviceKind.HomeMatic)
+ {
+ Ensure.IsNotNullOrWhitespace(senderAddress);
+ Ensure.IsNotNullOrWhitespace(receiverAddress);
+
+ return GetApi(kind).GetLinkInfoAsync(senderAddress, receiverAddress);
+ }
+
+ private IHomeMaticXmlRpcApi GetApi(CcuDeviceKind kind)
+ {
+ if (!xmlRpcApis.TryGetValue(kind, out var connection))
+ {
+ throw new KeyNotFoundException(
+ $"No XML-RPC API connection configured for device kind '{kind}'.");
+ }
+
+ return connection.Api;
}
}
diff --git a/source/CreativeCoders.HomeMatic/CcuDeviceBase.cs b/source/CreativeCoders.HomeMatic/CcuDeviceBase.cs
index 21e2dcd..e6ae622 100644
--- a/source/CreativeCoders.HomeMatic/CcuDeviceBase.cs
+++ b/source/CreativeCoders.HomeMatic/CcuDeviceBase.cs
@@ -10,6 +10,12 @@ namespace CreativeCoders.HomeMatic;
/// The XML-RPC API used to query parameter-set values and descriptions from the CCU.
public abstract class CcuDeviceBase(IHomeMaticXmlRpcApi api) : ICcuDeviceBase
{
+ ///
+ /// Gets the XML-RPC API used to talk to the CCU on behalf of this device or channel.
+ ///
+ /// The instance supplied via the constructor.
+ protected IHomeMaticXmlRpcApi Api { get; } = api;
+
///
public required CcuDeviceUri Uri { get; init; }
@@ -34,7 +40,7 @@ public abstract class CcuDeviceBase(IHomeMaticXmlRpcApi api) : ICcuDeviceBase
///
public async Task> GetParamSetValuesAsync(string paramSetKey)
{
- var paramSets = await api.GetParamSetAsync(Uri.Address, paramSetKey).ConfigureAwait(false);
+ var paramSets = await Api.GetParamSetAsync(Uri.Address, paramSetKey).ConfigureAwait(false);
return paramSets.Select(x => new ParamSetValue
{
@@ -47,7 +53,7 @@ public async Task> GetParamSetValuesAsync(string para
public async Task GetParamSetDescriptionsAsync(string paramSetKey)
{
var paramSetDescriptions =
- await api.GetParameterDescriptionAsync(Uri.Address, paramSetKey).ConfigureAwait(false);
+ await Api.GetParameterDescriptionAsync(Uri.Address, paramSetKey).ConfigureAwait(false);
return new CcuParameterDescriptions
{
diff --git a/source/CreativeCoders.HomeMatic/CcuDeviceChannel.cs b/source/CreativeCoders.HomeMatic/CcuDeviceChannel.cs
index 364486f..24469c3 100644
--- a/source/CreativeCoders.HomeMatic/CcuDeviceChannel.cs
+++ b/source/CreativeCoders.HomeMatic/CcuDeviceChannel.cs
@@ -1,13 +1,15 @@
+using CreativeCoders.Core;
using CreativeCoders.HomeMatic.Core.Devices;
using CreativeCoders.HomeMatic.XmlRpc.Client;
using CreativeCoders.HomeMatic.XmlRpc.Devices;
+using CreativeCoders.HomeMatic.XmlRpc.Links;
namespace CreativeCoders.HomeMatic;
///
/// Represents a single channel of a HomeMatic device.
///
-/// The XML-RPC API used to query parameter-set values and descriptions from the CCU.
+/// The XML-RPC API used to query parameter-set values and descriptions and to manage communication links.
public class CcuDeviceChannel(IHomeMaticXmlRpcApi api) : CcuDeviceBase(api), ICcuDeviceChannel
{
///
@@ -18,4 +20,60 @@ public class CcuDeviceChannel(IHomeMaticXmlRpcApi api) : CcuDeviceBase(api), ICc
///
public required ChannelDirection ChannelDirection { get; init; }
+
+ ///
+ public Task> GetLinksAsync(GetLinksFlags flags = GetLinksFlags.None)
+ {
+ return Api.GetLinksAsync(Uri.Address, flags);
+ }
+
+ ///
+ public Task> GetLinkPeersAsync()
+ {
+ return Api.GetLinkPeersAsync(Uri.Address);
+ }
+
+ ///
+ public Task AddLinkToAsync(string receiverAddress, string name = "", string description = "")
+ {
+ Ensure.IsNotNullOrWhitespace(receiverAddress);
+ Ensure.NotNull(name);
+ Ensure.NotNull(description);
+
+ return Api.AddLinkAsync(Uri.Address, receiverAddress, name, description);
+ }
+
+ ///
+ public Task RemoveLinkToAsync(string receiverAddress)
+ {
+ Ensure.IsNotNullOrWhitespace(receiverAddress);
+
+ return Api.RemoveLinkAsync(Uri.Address, receiverAddress);
+ }
+
+ ///
+ public Task SetLinkInfoAsync(string receiverAddress, string name, string description)
+ {
+ Ensure.IsNotNullOrWhitespace(receiverAddress);
+ Ensure.NotNull(name);
+ Ensure.NotNull(description);
+
+ return Api.SetLinkInfoAsync(Uri.Address, receiverAddress, name, description);
+ }
+
+ ///
+ public Task GetLinkInfoAsync(string receiverAddress)
+ {
+ Ensure.IsNotNullOrWhitespace(receiverAddress);
+
+ return Api.GetLinkInfoAsync(Uri.Address, receiverAddress);
+ }
+
+ ///
+ public Task ActivateLinkParamsetAsync(string peerAddress, bool longPress)
+ {
+ Ensure.IsNotNullOrWhitespace(peerAddress);
+
+ return Api.ActivateLinkParamsetAsync(Uri.Address, peerAddress, longPress);
+ }
}
diff --git a/source/CreativeCoders.HomeMatic/CompleteCcuDeviceBuilder.cs b/source/CreativeCoders.HomeMatic/CompleteCcuDeviceBuilder.cs
index 06b22a8..5a61233 100644
--- a/source/CreativeCoders.HomeMatic/CompleteCcuDeviceBuilder.cs
+++ b/source/CreativeCoders.HomeMatic/CompleteCcuDeviceBuilder.cs
@@ -12,9 +12,9 @@ namespace CreativeCoders.HomeMatic;
public class CompleteCcuDeviceBuilder : ICompleteCcuDeviceBuilder
{
///
- public async Task BuildAsync(ICcuDevice device)
+ public async Task BuildAsync(ICcuDevice device, CompleteCcuDeviceBuildOptions? options = null)
{
- var channels = await GetChannelsAsync(device).ConfigureAwait(false);
+ var channels = await GetChannelsAsync(device, options).ConfigureAwait(false);
var completeDevice = new CompleteCcuDevice
{
@@ -26,16 +26,22 @@ public async Task BuildAsync(ICcuDevice device)
return completeDevice;
}
- private static async Task> GetChannelsAsync(ICcuDevice device)
+ private static async Task> GetChannelsAsync(ICcuDevice device,
+ CompleteCcuDeviceBuildOptions? options)
{
var channels = new List();
foreach (var ccuDeviceChannel in device.Channels)
{
+ var links = options?.IncludeLinks == true
+ ? (await ccuDeviceChannel.GetLinksAsync(options.LinksFlags).ConfigureAwait(false)).ToArray()
+ : [];
+
var completeChannel = new CompleteCcuDeviceChannel
{
ChannelData = ccuDeviceChannel,
- ParamSetValues = await GetParamSetValuesAsync(ccuDeviceChannel).ConfigureAwait(false)
+ ParamSetValues = await GetParamSetValuesAsync(ccuDeviceChannel).ConfigureAwait(false),
+ Links = links
};
channels.Add(completeChannel);
diff --git a/source/CreativeCoders.HomeMatic/CompleteCcuDeviceChannel.cs b/source/CreativeCoders.HomeMatic/CompleteCcuDeviceChannel.cs
index cb5b2c6..1bcc2b2 100644
--- a/source/CreativeCoders.HomeMatic/CompleteCcuDeviceChannel.cs
+++ b/source/CreativeCoders.HomeMatic/CompleteCcuDeviceChannel.cs
@@ -1,4 +1,5 @@
using CreativeCoders.HomeMatic.Core.Devices;
+using CreativeCoders.HomeMatic.XmlRpc.Links;
namespace CreativeCoders.HomeMatic;
@@ -9,8 +10,11 @@ namespace CreativeCoders.HomeMatic;
public class CompleteCcuDeviceChannel : ICompleteCcuDeviceChannel
{
///
- public required ICcuDeviceChannelData ChannelData { get; init; }
+ public required ICcuDeviceChannel ChannelData { get; init; }
///
public required IEnumerable ParamSetValues { get; init; }
+
+ ///
+ public IEnumerable Links { get; init; } = [];
}
diff --git a/source/CreativeCoders.HomeMatic/CreativeCoders.HomeMatic.csproj b/source/CreativeCoders.HomeMatic/CreativeCoders.HomeMatic.csproj
index 6b1dc00..ce48c09 100644
--- a/source/CreativeCoders.HomeMatic/CreativeCoders.HomeMatic.csproj
+++ b/source/CreativeCoders.HomeMatic/CreativeCoders.HomeMatic.csproj
@@ -6,13 +6,13 @@
-
-
-
+
-
+
+
+
diff --git a/source/CreativeCoders.HomeMatic/Exporting/ChannelExportData.cs b/source/CreativeCoders.HomeMatic/Exporting/ChannelExportData.cs
index d18ce64..649c480 100644
--- a/source/CreativeCoders.HomeMatic/Exporting/ChannelExportData.cs
+++ b/source/CreativeCoders.HomeMatic/Exporting/ChannelExportData.cs
@@ -34,4 +34,13 @@ public class ChannelExportData
///
/// The enumerable of entries.
public required IEnumerable ParamSetValues { get; init; }
+
+ ///
+ /// Gets the communication links of the channel that passed the export filter.
+ ///
+ ///
+ /// The enumerable of entries, or when link
+ /// export is disabled. values are omitted from the JSON output.
+ ///
+ public IEnumerable? Links { get; init; }
}
diff --git a/source/CreativeCoders.HomeMatic/Exporting/DeviceExportOptions.cs b/source/CreativeCoders.HomeMatic/Exporting/DeviceExportOptions.cs
index d277603..d17267d 100644
--- a/source/CreativeCoders.HomeMatic/Exporting/DeviceExportOptions.cs
+++ b/source/CreativeCoders.HomeMatic/Exporting/DeviceExportOptions.cs
@@ -1,3 +1,5 @@
+using CreativeCoders.HomeMatic.Core;
+using CreativeCoders.HomeMatic.XmlRpc.Links;
using JetBrains.Annotations;
namespace CreativeCoders.HomeMatic.Exporting;
@@ -22,6 +24,22 @@ public class DeviceExportOptions
///
public bool WriteIndented { get; set; } = true;
+ ///
+ /// Gets or sets a value indicating whether the communication links of each channel are emitted
+ /// to the export. Links must already be present in the snapshot — see
+ /// .
+ ///
+ /// to emit links; otherwise, . Default is .
+ public bool IncludeLinks { get; set; }
+
+ ///
+ /// Gets or sets the flags that should be used when fetching the links during snapshot creation.
+ /// This value is intended to be forwarded to
+ /// by callers that build snapshots specifically for an export.
+ ///
+ /// The value. Default is .
+ public GetLinksFlags LinksFlags { get; set; } = GetLinksFlags.None;
+
///
/// Determines whether a ParamSet key is allowed based on the .
///
@@ -51,4 +69,17 @@ public bool IsParamValueNameAllowed(string paramValueName)
return ParamValueNameWhitelist.Contains(paramValueName, StringComparer.OrdinalIgnoreCase);
}
+
+ ///
+ /// Builds a matching this export configuration.
+ ///
+ /// A that includes links iff is set.
+ public CompleteCcuDeviceBuildOptions ToBuildOptions()
+ {
+ return new CompleteCcuDeviceBuildOptions
+ {
+ IncludeLinks = IncludeLinks,
+ LinksFlags = LinksFlags
+ };
+ }
}
diff --git a/source/CreativeCoders.HomeMatic/Exporting/DeviceExporter.cs b/source/CreativeCoders.HomeMatic/Exporting/DeviceExporter.cs
index 881970c..4ff5800 100644
--- a/source/CreativeCoders.HomeMatic/Exporting/DeviceExporter.cs
+++ b/source/CreativeCoders.HomeMatic/Exporting/DeviceExporter.cs
@@ -1,6 +1,7 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using CreativeCoders.HomeMatic.Core.Devices;
+using CreativeCoders.HomeMatic.XmlRpc.Links;
namespace CreativeCoders.HomeMatic.Exporting;
@@ -55,10 +56,24 @@ private static ChannelExportData BuildChannelExportData(
DeviceType = channel.ChannelData.DeviceType,
Index = channel.ChannelData.Index,
ParamSets = channel.ChannelData.ParamSets,
- ParamSetValues = BuildParamSetExportData(channel.ParamSetValues, options)
+ ParamSetValues = BuildParamSetExportData(channel.ParamSetValues, options),
+ Links = options?.IncludeLinks == true ? BuildLinkExportData(channel.Links) : null
};
}
+ private static IEnumerable BuildLinkExportData(IEnumerable links)
+ {
+ return links
+ .Select(link => new LinkExportData
+ {
+ Sender = link.Sender,
+ Receiver = link.Receiver,
+ Name = link.Name,
+ Description = link.Description
+ })
+ .ToList();
+ }
+
private static ParamSetExportData[] BuildParamSetExportData(
IEnumerable paramSetValues,
DeviceExportOptions? options)
diff --git a/source/CreativeCoders.HomeMatic/Exporting/LinkExportData.cs b/source/CreativeCoders.HomeMatic/Exporting/LinkExportData.cs
new file mode 100644
index 0000000..50893a8
--- /dev/null
+++ b/source/CreativeCoders.HomeMatic/Exporting/LinkExportData.cs
@@ -0,0 +1,34 @@
+using JetBrains.Annotations;
+
+namespace CreativeCoders.HomeMatic.Exporting;
+
+///
+/// Represents a single communication link as it appears in a device export.
+///
+[PublicAPI]
+public class LinkExportData
+{
+ ///
+ /// Gets the address of the sender of the link.
+ ///
+ /// The sender channel or device address.
+ public required string Sender { get; init; }
+
+ ///
+ /// Gets the address of the receiver of the link.
+ ///
+ /// The receiver channel or device address.
+ public required string Receiver { get; init; }
+
+ ///
+ /// Gets the human-readable name of the link.
+ ///
+ /// The link name; an empty string if not set on the CCU.
+ public required string Name { get; init; }
+
+ ///
+ /// Gets the textual description of the link.
+ ///
+ /// The link description; an empty string if not set on the CCU.
+ public required string Description { get; init; }
+}
diff --git a/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupResult.cs b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupResult.cs
index 67e016a..84c7716 100644
--- a/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupResult.cs
+++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupResult.cs
@@ -11,7 +11,7 @@ namespace CreativeCoders.HomeMatic.FirmwareBackup;
/// Always dispose the result to release the connection.
///
[PublicAPI]
-public sealed class FirmwareBackupResult : IAsyncDisposable, IDisposable
+public sealed class FirmwareBackupResult : IAsyncDisposable
{
private readonly IAsyncDisposable[] _additionalResources;
@@ -62,10 +62,4 @@ public async ValueTask DisposeAsync()
await resource.DisposeAsync().ConfigureAwait(false);
}
}
-
- ///
- public void Dispose()
- {
- DisposeAsync().AsTask().GetAwaiter().GetResult();
- }
}
diff --git a/source/CreativeCoders.HomeMatic/MultiCcuClient.cs b/source/CreativeCoders.HomeMatic/MultiCcuClient.cs
index 48de712..eb70a08 100644
--- a/source/CreativeCoders.HomeMatic/MultiCcuClient.cs
+++ b/source/CreativeCoders.HomeMatic/MultiCcuClient.cs
@@ -42,9 +42,10 @@ public Task GetDeviceAsync(string address)
}
///
- public async Task> GetCompleteDevicesAsync()
+ public async Task> GetCompleteDevicesAsync(
+ CompleteCcuDeviceBuildOptions? buildOptions = null)
{
- var results = await GetDataFromClientsAsync(x => x.GetCompleteDevicesAsync()).ConfigureAwait(false);
+ var results = await GetDataFromClientsAsync(x => x.GetCompleteDevicesAsync(buildOptions)).ConfigureAwait(false);
RegisterRoutes(results.SelectMany(pair =>
pair.Items.Select(item => (item.DeviceData.Uri.Address, pair.Client))));
@@ -53,12 +54,13 @@ public async Task> GetCompleteDevicesAsync()
}
///
- public Task GetCompleteDeviceAsync(string address)
+ public Task GetCompleteDeviceAsync(string address,
+ CompleteCcuDeviceBuildOptions? buildOptions = null)
{
Ensure.IsNotNullOrWhitespace(address);
return InvokeWithRoutingAsync(address,
- (client, deviceAddress) => client.GetCompleteDeviceAsync(deviceAddress));
+ (client, deviceAddress) => client.GetCompleteDeviceAsync(deviceAddress, buildOptions));
}
// Generic helper that routes a per-device call through the routing table. Can be reused by future
diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/CliBaseServiceCollectionExtensions.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/CliBaseServiceCollectionExtensions.cs
index 861326c..eb8c797 100644
--- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/CliBaseServiceCollectionExtensions.cs
+++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/CliBaseServiceCollectionExtensions.cs
@@ -1,5 +1,7 @@
using CreativeCoders.HomeMatic.Core;
using CreativeCoders.HomeMatic.Tools.Cli.Base.Commanding;
+using CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Output;
+using CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Serialization;
using CreativeCoders.HomeMatic.Tools.Cli.Base.Connections;
using CreativeCoders.HomeMatic.Tools.Cli.Base.SharedData;
using Microsoft.Extensions.DependencyInjection;
@@ -12,8 +14,6 @@ public static class CliBaseServiceCollectionExtensions
public static void AddHomeMaticCliBase(this IServiceCollection services)
{
services.TryAddSingleton();
- services.TryAddSingleton();
-
services.TryAddSingleton();
services.TryAddSingleton();
@@ -21,6 +21,11 @@ public static void AddHomeMaticCliBase(this IServiceCollection services)
services.TryAddSingleton(sp =>
sp.GetRequiredService().BuildMultiCcuClient());
+ services.TryAddEnumerable(ServiceDescriptor.Singleton());
+ services.TryAddEnumerable(ServiceDescriptor.Singleton());
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+
services.AddHomeMatic();
}
}
diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/CliBaseCommand.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/CliBaseCommand.cs
deleted file mode 100644
index af703c9..0000000
--- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/CliBaseCommand.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-using CreativeCoders.Core;
-using CreativeCoders.HomeMatic.Tools.Cli.Base.SharedData;
-using CreativeCoders.HomeMatic.XmlRpc;
-using CreativeCoders.HomeMatic.XmlRpc.Client;
-
-namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Commanding;
-
-public abstract class CliBaseCommand(IHomeMaticXmlRpcApiBuilder apiBuilder, ISharedData sharedData)
-{
- private readonly IHomeMaticXmlRpcApiBuilder _apiBuilder = Ensure.NotNull(apiBuilder);
-
- protected IHomeMaticXmlRpcApi BuildApi()
- {
- var cliData = SharedData.LoadCliData();
-
- return _apiBuilder
- .ForUrl(new Uri($"http://{cliData.CcuHost}:{CcuRpcPorts.HomeMaticIp}"))
- .Build();
- }
-
- protected ISharedData SharedData { get; } = Ensure.NotNull(sharedData);
-}
diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/CliCommandBase.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/CliCommandBase.cs
deleted file mode 100644
index 9f1d35f..0000000
--- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/CliCommandBase.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Commanding;
-
-public abstract class CliDataCommandBase : IHomeMaticCliCommandWithOptions
- where TOptions : class
-{
- public Task ExecuteAsync(TOptions options)
- {
- throw new NotImplementedException();
- }
-
- protected abstract Task LoadDataAsync();
-}
diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/CliCommandExecutor.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/CliCommandExecutor.cs
deleted file mode 100644
index 6c64d0c..0000000
--- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/CliCommandExecutor.cs
+++ /dev/null
@@ -1,35 +0,0 @@
-using CreativeCoders.Core;
-using CreativeCoders.Core.Reflection;
-using CreativeCoders.SysConsole.Cli.Actions;
-using CreativeCoders.SysConsole.Cli.Actions.Exceptions;
-
-namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Commanding;
-
-public class CliCommandExecutor : ICliCommandExecutor
-{
- private readonly IServiceProvider _serviceProvider;
-
- public CliCommandExecutor(IServiceProvider serviceProvider)
- {
- _serviceProvider = Ensure.NotNull(serviceProvider);
- }
-
- public async Task ExecuteAsync(TOptions options)
- where TCommand : IHomeMaticCliCommandWithOptions where TOptions : class
- {
- var command = typeof(TCommand).CreateInstance>(_serviceProvider);
-
- return command != null
- ? new CliActionResult(await command.ExecuteAsync(options))
- : throw new CliActionException("Command object cannot be created");
- }
-
- public async Task ExecuteAsync() where TCommand : IHomeMaticCliCommand
- {
- var command = typeof(TCommand).CreateInstance(_serviceProvider);
-
- return command != null
- ? new CliActionResult(await command.ExecuteAsync())
- : throw new CliActionException("Command object cannot be created");
- }
-}
\ No newline at end of file
diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/ICliCommandExecutor.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/ICliCommandExecutor.cs
deleted file mode 100644
index 193c697..0000000
--- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/ICliCommandExecutor.cs
+++ /dev/null
@@ -1,13 +0,0 @@
-using CreativeCoders.SysConsole.Cli.Actions;
-
-namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Commanding;
-
-public interface ICliCommandExecutor
-{
- Task ExecuteAsync(TOptions options)
- where TCommand : IHomeMaticCliCommandWithOptions
- where TOptions : class;
-
- Task ExecuteAsync()
- where TCommand : IHomeMaticCliCommand;
-}
\ No newline at end of file
diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/IHomeMaticCliCommand.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/IHomeMaticCliCommand.cs
deleted file mode 100644
index 391c1d8..0000000
--- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/IHomeMaticCliCommand.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Commanding;
-
-public interface IHomeMaticCliCommand
-{
- Task ExecuteAsync();
-}
diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/IHomeMaticCliCommandWithOptions.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/IHomeMaticCliCommandWithOptions.cs
deleted file mode 100644
index 28338f8..0000000
--- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commanding/IHomeMaticCliCommandWithOptions.cs
+++ /dev/null
@@ -1,7 +0,0 @@
-namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Commanding;
-
-public interface IHomeMaticCliCommandWithOptions
- where TOptions : class
-{
- Task ExecuteAsync(TOptions options);
-}
diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/DataOutputCommandBase.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/DataOutputCommandBase.cs
new file mode 100644
index 0000000..40b78bd
--- /dev/null
+++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/DataOutputCommandBase.cs
@@ -0,0 +1,127 @@
+using CreativeCoders.Cli.Core;
+using CreativeCoders.Core;
+using CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Output;
+using CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Serialization;
+using JetBrains.Annotations;
+using Spectre.Console;
+
+namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Commands;
+
+///
+/// Generic base class for CLI commands that load arbitrary data, serialize it as JSON or YAML
+/// and write the result either to a file or to stdout.
+///
+/// The type of data produced by .
+/// The type of CLI options. Must implement .
+[PublicAPI]
+public abstract class DataOutputCommandBase : ICliCommand
+ where TOptions : class, IDataOutputOptions
+{
+ private readonly IDataSerializerFactory _serializerFactory;
+
+ private readonly IDataOutputWriter _outputWriter;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The console used for status messages and stdout output.
+ /// The factory that resolves serializers for a format.
+ /// The writer that targets file or stdout.
+ protected DataOutputCommandBase(
+ IAnsiConsole console,
+ IDataSerializerFactory serializerFactory,
+ IDataOutputWriter outputWriter)
+ {
+ Console = Ensure.NotNull(console);
+ _serializerFactory = Ensure.NotNull(serializerFactory);
+ _outputWriter = Ensure.NotNull(outputWriter);
+ }
+
+ ///
+ /// Gets the console available to subclasses for additional output.
+ ///
+ protected IAnsiConsole Console { get; }
+
+ ///
+ public async Task ExecuteAsync(TOptions options)
+ {
+ Ensure.NotNull(options);
+
+ var format = _outputWriter.ResolveFormat(options.OutputFormat, options.OutputFile);
+
+ await OnBeforeLoadAsync(options).ConfigureAwait(false);
+
+ var data = await LoadDataAsync(options).ConfigureAwait(false);
+
+ var transformed = TransformData(data, options);
+
+ Ensure.NotNull(transformed);
+
+ var serializer = _serializerFactory.Create(format);
+ var content = serializer.Serialize(transformed);
+
+ await OnBeforeWriteAsync(options, format).ConfigureAwait(false);
+
+ await _outputWriter.WriteAsync(content, options.OutputFile).ConfigureAwait(false);
+
+ await OnAfterWriteAsync(options, format).ConfigureAwait(false);
+
+ return CommandResult.Success;
+ }
+
+ ///
+ /// Loads the data to be serialized.
+ ///
+ /// The CLI options.
+ /// The loaded data.
+ protected abstract Task LoadDataAsync(TOptions options);
+
+ ///
+ /// Transforms the loaded data into the object that is actually serialized. The default
+ /// implementation returns unchanged.
+ ///
+ /// The loaded data.
+ /// The CLI options.
+ /// The object passed to the serializer.
+ protected virtual object TransformData(TData data, TOptions options) => data!;
+
+ ///
+ /// Hook invoked before data is loaded. The default implementation writes a status message
+ /// to the console.
+ ///
+ /// The CLI options.
+ /// A task that completes when the hook is finished.
+ protected virtual Task OnBeforeLoadAsync(TOptions options)
+ {
+ Console.WriteLine("Loading data...");
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Hook invoked after serialization but before writing the result. The default implementation
+ /// writes a status message describing the resolved target.
+ ///
+ /// The CLI options.
+ /// The resolved output format.
+ /// A task that completes when the hook is finished.
+ protected virtual Task OnBeforeWriteAsync(TOptions options, DataOutputFormat resolvedFormat)
+ {
+ var target = string.IsNullOrWhiteSpace(options.OutputFile)
+ ? "stdout"
+ : $"file '{options.OutputFile}'";
+
+ Console.WriteLine($"Writing {resolvedFormat} output to {target}");
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Hook invoked after the result has been written. The default implementation is a no-op.
+ ///
+ /// The CLI options.
+ /// The resolved output format.
+ /// A task that completes when the hook is finished.
+ protected virtual Task OnAfterWriteAsync(TOptions options, DataOutputFormat resolvedFormat)
+ => Task.CompletedTask;
+}
diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/DataOutputFormat.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/DataOutputFormat.cs
new file mode 100644
index 0000000..6f9b685
--- /dev/null
+++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/DataOutputFormat.cs
@@ -0,0 +1,22 @@
+namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Commands;
+
+///
+/// Specifies the serialization format used to write CLI command output.
+///
+public enum DataOutputFormat
+{
+ ///
+ /// Format is derived from the output file extension; falls back to .
+ ///
+ Auto = 0,
+
+ ///
+ /// JSON output.
+ ///
+ Json = 1,
+
+ ///
+ /// YAML output.
+ ///
+ Yaml = 2
+}
diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/IDataOutputOptions.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/IDataOutputOptions.cs
new file mode 100644
index 0000000..0484094
--- /dev/null
+++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/IDataOutputOptions.cs
@@ -0,0 +1,18 @@
+namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Commands;
+
+///
+/// Contract for CLI command options that control serialized data output.
+///
+public interface IDataOutputOptions
+{
+ ///
+ /// Gets the desired output format. When set to
+ /// the format is derived from the output file extension.
+ ///
+ DataOutputFormat OutputFormat { get; }
+
+ ///
+ /// Gets the path of the output file. When null or empty the data is written to stdout.
+ ///
+ string? OutputFile { get; }
+}
diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Output/DataOutputWriter.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Output/DataOutputWriter.cs
new file mode 100644
index 0000000..dfd88b5
--- /dev/null
+++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Output/DataOutputWriter.cs
@@ -0,0 +1,74 @@
+using CreativeCoders.Core;
+using CreativeCoders.Core.IO;
+using Spectre.Console;
+
+namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Output;
+
+///
+/// Default implementation. Writes content either to a file via
+/// or to the provided .
+///
+public class DataOutputWriter : IDataOutputWriter
+{
+ private readonly IAnsiConsole _console;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The console used for stdout output.
+ public DataOutputWriter(IAnsiConsole console)
+ {
+ _console = Ensure.NotNull(console);
+ }
+
+ ///
+ public DataOutputFormat ResolveFormat(DataOutputFormat requestedFormat, string? outputFile)
+ {
+ var fromExtension = TryGetFormatFromExtension(outputFile);
+
+ if (fromExtension.HasValue)
+ {
+ return fromExtension.Value;
+ }
+
+ return requestedFormat == DataOutputFormat.Auto
+ ? DataOutputFormat.Json
+ : requestedFormat;
+ }
+
+ ///
+ public async Task WriteAsync(string content, string? outputFile)
+ {
+ Ensure.NotNull(content);
+
+ if (string.IsNullOrWhiteSpace(outputFile))
+ {
+ _console.WriteLine(content);
+ return;
+ }
+
+ await FileSys.File.WriteAllTextAsync(outputFile, content).ConfigureAwait(false);
+ }
+
+ private static DataOutputFormat? TryGetFormatFromExtension(string? outputFile)
+ {
+ if (string.IsNullOrWhiteSpace(outputFile))
+ {
+ return null;
+ }
+
+ var extension = Path.GetExtension(outputFile);
+
+ if (string.IsNullOrEmpty(extension))
+ {
+ return null;
+ }
+
+ return extension.ToLowerInvariant() switch
+ {
+ ".json" => DataOutputFormat.Json,
+ ".yaml" or ".yml" => DataOutputFormat.Yaml,
+ _ => null
+ };
+ }
+}
diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Output/IDataOutputWriter.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Output/IDataOutputWriter.cs
new file mode 100644
index 0000000..4e97bb0
--- /dev/null
+++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Output/IDataOutputWriter.cs
@@ -0,0 +1,24 @@
+namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Output;
+
+///
+/// Resolves the effective output format and writes serialized data either to a file or to stdout.
+///
+public interface IDataOutputWriter
+{
+ ///
+ /// Determines the effective based on the requested format
+ /// and the output file extension.
+ ///
+ /// The format requested via options.
+ /// The path of the output file, or null/empty for stdout.
+ /// The resolved, concrete output format.
+ DataOutputFormat ResolveFormat(DataOutputFormat requestedFormat, string? outputFile);
+
+ ///
+ /// Writes the serialized to the configured target.
+ ///
+ /// The already serialized content.
+ /// The target file path. When null or empty, the content is written to stdout.
+ /// A task that completes when the content has been written.
+ Task WriteAsync(string content, string? outputFile);
+}
diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Serialization/DataSerializerFactory.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Serialization/DataSerializerFactory.cs
new file mode 100644
index 0000000..14a2c2f
--- /dev/null
+++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Serialization/DataSerializerFactory.cs
@@ -0,0 +1,40 @@
+using CreativeCoders.Core;
+
+namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Serialization;
+
+///
+/// Default implementation backed by all registered
+/// instances.
+///
+public class DataSerializerFactory : IDataSerializerFactory
+{
+ private readonly Dictionary _serializers;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// All registered serializers.
+ public DataSerializerFactory(IEnumerable serializers)
+ {
+ _serializers = Ensure.NotNull(serializers).ToDictionary(s => s.Format);
+ }
+
+ ///
+ public IDataSerializer Create(DataOutputFormat format)
+ {
+ if (format == DataOutputFormat.Auto)
+ {
+ throw new ArgumentException(
+ "Format must be resolved to a concrete value before requesting a serializer.",
+ nameof(format));
+ }
+
+ if (!_serializers.TryGetValue(format, out var serializer))
+ {
+ throw new InvalidOperationException(
+ $"No serializer registered for format '{format}'.");
+ }
+
+ return serializer;
+ }
+}
diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Serialization/IDataSerializer.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Serialization/IDataSerializer.cs
new file mode 100644
index 0000000..9819ff8
--- /dev/null
+++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Serialization/IDataSerializer.cs
@@ -0,0 +1,19 @@
+namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Serialization;
+
+///
+/// Serializes arbitrary data into a string representation for a specific output format.
+///
+public interface IDataSerializer
+{
+ ///
+ /// Gets the format produced by this serializer.
+ ///
+ DataOutputFormat Format { get; }
+
+ ///
+ /// Serializes the given to its string representation.
+ ///
+ /// The data to serialize.
+ /// The serialized representation of the data.
+ string Serialize(object data);
+}
diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Serialization/IDataSerializerFactory.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Serialization/IDataSerializerFactory.cs
new file mode 100644
index 0000000..709afc6
--- /dev/null
+++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Serialization/IDataSerializerFactory.cs
@@ -0,0 +1,14 @@
+namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Serialization;
+
+///
+/// Creates instances for a requested .
+///
+public interface IDataSerializerFactory
+{
+ ///
+ /// Returns the registered for the given .
+ ///
+ /// The desired output format. Must not be .
+ /// A serializer that produces output in .
+ IDataSerializer Create(DataOutputFormat format);
+}
diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Serialization/JsonDataSerializer.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Serialization/JsonDataSerializer.cs
new file mode 100644
index 0000000..066cb5a
--- /dev/null
+++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Serialization/JsonDataSerializer.cs
@@ -0,0 +1,30 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using CreativeCoders.Core;
+
+namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Serialization;
+
+///
+/// implementation that produces indented JSON using
+/// System.Text.Json.
+///
+public class JsonDataSerializer : IDataSerializer
+{
+ private static readonly JsonSerializerOptions Options = new()
+ {
+ WriteIndented = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
+ };
+
+ ///
+ public DataOutputFormat Format => DataOutputFormat.Json;
+
+ ///
+ public string Serialize(object data)
+ {
+ Ensure.NotNull(data);
+
+ return JsonSerializer.Serialize(data, Options);
+ }
+}
diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Serialization/YamlDataSerializer.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Serialization/YamlDataSerializer.cs
new file mode 100644
index 0000000..b54c94c
--- /dev/null
+++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/Commands/Serialization/YamlDataSerializer.cs
@@ -0,0 +1,27 @@
+using CreativeCoders.Core;
+using YamlDotNet.Serialization;
+using YamlDotNet.Serialization.NamingConventions;
+
+namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Serialization;
+
+///
+/// implementation that produces YAML using YamlDotNet.
+///
+public class YamlDataSerializer : IDataSerializer
+{
+ private static readonly ISerializer Serializer = new SerializerBuilder()
+ .WithNamingConvention(CamelCaseNamingConvention.Instance)
+ .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull)
+ .Build();
+
+ ///
+ public DataOutputFormat Format => DataOutputFormat.Yaml;
+
+ ///
+ public string Serialize(object data)
+ {
+ Ensure.NotNull(data);
+
+ return Serializer.Serialize(data);
+ }
+}
diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/CreativeCoders.HomeMatic.Tools.Cli.Base.csproj b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/CreativeCoders.HomeMatic.Tools.Cli.Base.csproj
index 9ca760f..50a7d4e 100644
--- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/CreativeCoders.HomeMatic.Tools.Cli.Base.csproj
+++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Base/CreativeCoders.HomeMatic.Tools.Cli.Base.csproj
@@ -12,6 +12,7 @@
+
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 069ca65..3fda6e1 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
@@ -22,21 +22,6 @@ protected override object TransformData(ICompleteCcuDevice device)
{
WriteIndented = true
});
- return new
- {
- Device = device.DeviceData,
- Channels = device.Channels.Select(x =>
- {
- var channel = new
- {
- Info = x.ChannelData,
- ParamSets = x.ParamSetValues
- };
-
- return channel;
- }),
- ParamSets = device.ParamSetValues
- };
}
protected override Task LoadDataAsync(IMultiCcuClient ccuClient, ExportDevicesOptions options)
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 d9abfa6..adeae13 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,7 @@
using CreativeCoders.Cli.Core;
using CreativeCoders.Core;
-using CreativeCoders.Core.Collections;
using CreativeCoders.HomeMatic.Core;
-using CreativeCoders.HomeMatic.Core.Devices;
+using CreativeCoders.HomeMatic.Exporting;
using CreativeCoders.SysConsole.Core;
using JetBrains.Annotations;
using Spectre.Console;
@@ -10,64 +9,112 @@
namespace CreativeCoders.HomeMatic.Tools.Cli.Commands.Device.ShowDetails;
[UsedImplicitly]
-public class ShowDeviceDetailsCommand(IAnsiConsole console, IMultiCcuClient multiCcuClient)
+[CliCommand([DeviceCommandGroup.Name, "details"], Description = "Show details for a device")]
+public class ShowDeviceDetailsCommand(
+ IAnsiConsole console,
+ IMultiCcuClient multiCcuClient,
+ IDeviceExporter deviceExporter)
: ICliCommand
{
private readonly IMultiCcuClient _multiCcuClient = Ensure.NotNull(multiCcuClient);
private readonly IAnsiConsole _console = Ensure.NotNull(console);
+ private readonly IDeviceExporter _deviceExporter = Ensure.NotNull(deviceExporter);
+
public async Task ExecuteAsync(ShowDeviceDetailsOptions options)
{
- var device = await _multiCcuClient.GetCompleteDeviceAsync(options.Address).ConfigureAwait(false);
+ var exportOptions = new DeviceExportOptions
+ {
+ IncludeLinks = true
+ };
+
+ var device = await _multiCcuClient
+ .GetCompleteDeviceAsync(options.Address, exportOptions.ToBuildOptions())
+ .ConfigureAwait(false);
+
+ var exportData = _deviceExporter.BuildExportData(device, exportOptions);
_console.WriteLine($"Show device details for '{options.Address}'");
_console.WriteLine();
- PrintDevice(device);
+ PrintDevice(exportData);
return CommandResult.Success;
}
- private void PrintDevice(ICompleteCcuDevice device)
+ private void PrintDevice(DeviceExportData device)
{
- _console.MarkupLine($"Name: [bold teal]{device.DeviceData.Name}[/]");
- _console.MarkupLine($"Address: [bold]{device.DeviceData.Uri.Address}[/]");
- _console.MarkupLine($"Ccu: [bold yellow]{device.DeviceData.Uri.HostDisplayName}[/]");
- _console.MarkupLine($"Type: {device.DeviceData.DeviceType}");
+ _console.MarkupLine($"Name: [bold teal]{Markup.Escape(device.Name)}[/]");
+ _console.MarkupLine($"Address: [bold]{Markup.Escape(device.Address)}[/]");
+ _console.MarkupLine($"Ccu: [bold yellow]{Markup.Escape(device.Ccu)}[/]");
+ _console.MarkupLine($"Type: {Markup.Escape(device.DeviceType)}");
+ _console.MarkupLine($"Firmware: {Markup.Escape(device.FirmwareVersion)}");
+ _console.MarkupLine($"ParamSet keys: {Markup.Escape(string.Join(", ", device.ParamSetKeys))}");
_console.WriteLine();
+ _console.WriteLine("Device ParamSets:");
+ PrintParamSets(device.ParamSetValues, " ");
- _console.WriteLine(" Device ParamSets:");
-
+ _console.WriteLine();
_console.WriteLine("Channels:");
- device.Channels.ForEach(PrintChannel);
-
- PrintParamSets(device.ParamSetValues, " ");
+ foreach (var channel in device.Channels)
+ {
+ PrintChannel(channel);
+ }
}
- private void PrintChannel(ICompleteCcuDeviceChannel channel)
+ private void PrintChannel(ChannelExportData channel)
{
- _console.WriteLine($" - Index: {channel.ChannelData.Index}");
- _console.WriteLine($" Address: {channel.ChannelData.Uri.Address}");
- _console.WriteLine($" Type: {channel.ChannelData.DeviceType}");
+ _console.WriteLine($" - Index: {channel.Index}");
+ _console.WriteLine($" Address: {channel.Address}");
+ _console.WriteLine($" Type: {channel.DeviceType}");
+ _console.WriteLine($" ParamSet keys: {string.Join(", ", channel.ParamSets)}");
+
_console.WriteLine(" Channel ParamSets:");
+ PrintParamSets(channel.ParamSetValues, " ");
+
+ if (channel.Links is not null)
+ {
+ PrintLinks(channel.Links, " ");
+ }
+ }
+
+ private void PrintParamSets(IEnumerable paramSets, string indent)
+ {
+ foreach (var paramSet in paramSets)
+ {
+ _console.WriteLine($"{indent}- ParamSet: {paramSet.ParamSetKey}");
- PrintParamSets(channel.ParamSetValues, " ");
+ foreach (var value in paramSet.Values)
+ {
+ var label = value.Name is not null && !string.Equals(value.Name, value.Key, StringComparison.Ordinal)
+ ? $"{value.Key} ({value.Name})"
+ : value.Key;
+
+ _console.WriteLine($"{indent} - {label} : {value.Value}");
+ }
+ }
}
- private void PrintParamSets(IEnumerable paramSetValuesWithDescriptions,
- string indent)
+ private void PrintLinks(IEnumerable links, string indent)
{
- foreach (var paramSet in paramSetValuesWithDescriptions)
+ var linkList = links.ToList();
+
+ if (linkList.Count == 0)
{
- var values = paramSet.ParamSetValues;
+ return;
+ }
- _console.WriteLine($"{indent}- ParamSet: {paramSet.ParamSetKey}");
+ _console.WriteLine($"{indent}Links:");
- _console.WriteLines(values.Select(x => $"{indent} - {x.ParamSetValue.Name} : {x.ParamSetValue.Value}")
- .ToArray());
+ foreach (var link in linkList)
+ {
+ _console.WriteLine($"{indent} - Sender: {link.Sender}");
+ _console.WriteLine($"{indent} Receiver: {link.Receiver}");
+ _console.WriteLine($"{indent} Name: {link.Name}");
+ _console.WriteLine($"{indent} Description: {link.Description}");
}
}
}
diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Test/TestCommand.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Test/TestCommand.cs
deleted file mode 100644
index 9085569..0000000
--- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Test/TestCommand.cs
+++ /dev/null
@@ -1,53 +0,0 @@
-using System.Text.Json;
-using CreativeCoders.Cli.Core;
-using CreativeCoders.Core;
-using CreativeCoders.HomeMatic.JsonRpc.Api;
-using CreativeCoders.HomeMatic.Tools.Cli.Base.Commanding;
-using CreativeCoders.HomeMatic.Tools.Cli.Base.SharedData;
-using CreativeCoders.HomeMatic.XmlRpc.Client;
-using Spectre.Console;
-
-namespace CreativeCoders.HomeMatic.Tools.Cli.Commands.Test;
-
-public class TestCommand(
- IAnsiConsole console,
- IHomeMaticXmlRpcApiBuilder apiBuilder,
- ISharedData sharedData,
- IHomeMaticJsonRpcApiBuilder jsonRpcApiBuilder)
- : CliBaseCommand(apiBuilder, sharedData), ICliCommand
-{
- private readonly IAnsiConsole _console = Ensure.NotNull(console);
-
- public async Task ExecuteAsync()
- {
- var cliData = SharedData.LoadCliData();
- if (!cliData.Users.TryGetValue(cliData.CcuHost, out var userName))
- {
- userName = await _console.PromptAsync(new TextPrompt("User name: "));
- cliData.Users[cliData.CcuHost] = userName;
-
- SharedData.SaveCliData(cliData);
- }
-
- var api = jsonRpcApiBuilder.ForUrl(new Uri($"http://{cliData.CcuHost}/api/homematic.cgi")).Build();
-
- var loginResponse = await api.LoginAsync(userName, SharedData.GetPassword(cliData.CcuHost));
-
- _console.WriteLine($"Login Response: {JsonSerializer.Serialize(loginResponse)}");
-
- if (loginResponse.Result == null)
- {
- return 1;
- }
-
- var listAllDetailsResponse = await api.ListAllDetailsAsync(loginResponse.Result);
-
- _console.WriteLine($"All Details Response: {JsonSerializer.Serialize(listAllDetailsResponse)}");
-
- var logoutResponse = await api.LogoutAsync(loginResponse.Result);
-
- _console.WriteLine($"Logout Response: {JsonSerializer.Serialize(logoutResponse)}");
-
- return CommandResult.Success;
- }
-}
diff --git a/tests/CreativeCoders.HomeMatic.Tests/CcuClientLinkTests.cs b/tests/CreativeCoders.HomeMatic.Tests/CcuClientLinkTests.cs
new file mode 100644
index 0000000..b8acaf0
--- /dev/null
+++ b/tests/CreativeCoders.HomeMatic.Tests/CcuClientLinkTests.cs
@@ -0,0 +1,279 @@
+using CreativeCoders.HomeMatic.Core;
+using CreativeCoders.HomeMatic.JsonRpc;
+using CreativeCoders.HomeMatic.XmlRpc;
+using CreativeCoders.HomeMatic.XmlRpc.Client;
+using CreativeCoders.HomeMatic.XmlRpc.Links;
+using FakeItEasy;
+using AwesomeAssertions;
+
+namespace CreativeCoders.HomeMatic.Tests;
+
+public class CcuClientLinkTests
+{
+ private const string SenderAddress = "BIDCOS:1";
+ private const string ReceiverAddress = "BIDCOS:2";
+
+ [Fact]
+ public async Task GetAllLinksAsync_DefaultKind_CallsHomeMaticApiWithEmptyAddress()
+ {
+ // Arrange
+ var homeMaticApi = A.Fake();
+ var homeMaticIpApi = A.Fake();
+ var expected = new[] { new Link { Sender = SenderAddress, Receiver = ReceiverAddress } };
+ A.CallTo(() => homeMaticApi.GetLinksAsync(string.Empty, (int)GetLinksFlags.None))
+ .Returns(Task.FromResult>(expected));
+
+ var client = CreateClient(homeMaticApi, homeMaticIpApi);
+
+ // Act
+ var result = await client.GetAllLinksAsync();
+
+ // Assert
+ result.Should().BeEquivalentTo(expected);
+ A.CallTo(() => homeMaticIpApi.GetLinksAsync(A._, A._))
+ .MustNotHaveHappened();
+ }
+
+ [Fact]
+ public async Task GetAllLinksAsync_WithSpecificKind_CallsCorrespondingApi()
+ {
+ // Arrange
+ var homeMaticApi = A.Fake();
+ var homeMaticIpApi = A.Fake();
+ const GetLinksFlags flags = GetLinksFlags.SenderParamSet;
+ A.CallTo(() => homeMaticIpApi.GetLinksAsync(string.Empty, (int)flags))
+ .Returns(Task.FromResult>([]));
+
+ var client = CreateClient(homeMaticApi, homeMaticIpApi);
+
+ // Act
+ await client.GetAllLinksAsync(CcuDeviceKind.HomeMaticIp, flags);
+
+ // Assert
+ A.CallTo(() => homeMaticIpApi.GetLinksAsync(string.Empty, (int)flags))
+ .MustHaveHappenedOnceExactly();
+ A.CallTo(() => homeMaticApi.GetLinksAsync(A._, A._))
+ .MustNotHaveHappened();
+ }
+
+ [Fact]
+ public async Task AddLinkAsync_DefaultKind_DelegatesToHomeMaticApi()
+ {
+ // Arrange
+ var homeMaticApi = A.Fake();
+ var homeMaticIpApi = A.Fake();
+ var client = CreateClient(homeMaticApi, homeMaticIpApi);
+
+ // Act
+ await client.AddLinkAsync(SenderAddress, ReceiverAddress, "n", "d");
+
+ // Assert
+ A.CallTo(() => homeMaticApi.AddLinkAsync(SenderAddress, ReceiverAddress, "n", "d"))
+ .MustHaveHappenedOnceExactly();
+ }
+
+ [Fact]
+ public async Task AddLinkAsync_WithIpKind_DelegatesToHomeMaticIpApi()
+ {
+ // Arrange
+ var homeMaticApi = A.Fake();
+ var homeMaticIpApi = A.Fake();
+ var client = CreateClient(homeMaticApi, homeMaticIpApi);
+
+ // Act
+ await client.AddLinkAsync(SenderAddress, ReceiverAddress, kind: CcuDeviceKind.HomeMaticIp);
+
+ // Assert
+ A.CallTo(() => homeMaticIpApi.AddLinkAsync(SenderAddress, ReceiverAddress, string.Empty, string.Empty))
+ .MustHaveHappenedOnceExactly();
+ A.CallTo(() => homeMaticApi.AddLinkAsync(A._, A._, A._, A._))
+ .MustNotHaveHappened();
+ }
+
+ [Theory]
+ [InlineData(null, "BIDCOS:2")]
+ [InlineData("", "BIDCOS:2")]
+ [InlineData(" ", "BIDCOS:2")]
+ [InlineData("BIDCOS:1", null)]
+ [InlineData("BIDCOS:1", "")]
+ [InlineData("BIDCOS:1", " ")]
+ public async Task AddLinkAsync_NullOrWhitespaceAddress_ThrowsAndDoesNotCallApi(string? sender,
+ string? receiver)
+ {
+ // Arrange
+ var homeMaticApi = A.Fake();
+ var homeMaticIpApi = A.Fake();
+ var client = CreateClient(homeMaticApi, homeMaticIpApi);
+
+ // Act
+ var act = () => client.AddLinkAsync(sender!, receiver!);
+
+ // Assert
+ await act.Should().ThrowAsync();
+ A.CallTo(homeMaticApi).MustNotHaveHappened();
+ A.CallTo(homeMaticIpApi).MustNotHaveHappened();
+ }
+
+ [Fact]
+ public async Task AddLinkAsync_NullName_ThrowsAndDoesNotCallApi()
+ {
+ // Arrange
+ var homeMaticApi = A.Fake();
+ var homeMaticIpApi = A.Fake();
+ var client = CreateClient(homeMaticApi, homeMaticIpApi);
+
+ // Act
+ var act = () => client.AddLinkAsync(SenderAddress, ReceiverAddress, null!, "d");
+
+ // Assert
+ await act.Should().ThrowAsync();
+ A.CallTo(homeMaticApi).MustNotHaveHappened();
+ }
+
+ [Fact]
+ public async Task RemoveLinkAsync_DelegatesToApiOfRequestedKind()
+ {
+ // Arrange
+ var homeMaticApi = A.Fake();
+ var homeMaticIpApi = A.Fake();
+ var client = CreateClient(homeMaticApi, homeMaticIpApi);
+
+ // Act
+ await client.RemoveLinkAsync(SenderAddress, ReceiverAddress, CcuDeviceKind.HomeMaticIp);
+
+ // Assert
+ A.CallTo(() => homeMaticIpApi.RemoveLinkAsync(SenderAddress, ReceiverAddress))
+ .MustHaveHappenedOnceExactly();
+ }
+
+ [Fact]
+ public async Task SetLinkInfoAsync_DelegatesToApi()
+ {
+ // Arrange
+ var homeMaticApi = A.Fake();
+ var homeMaticIpApi = A.Fake();
+ var client = CreateClient(homeMaticApi, homeMaticIpApi);
+
+ // Act
+ await client.SetLinkInfoAsync(SenderAddress, ReceiverAddress, "name", "desc");
+
+ // Assert
+ A.CallTo(() => homeMaticApi.SetLinkInfoAsync(SenderAddress, ReceiverAddress, "name", "desc"))
+ .MustHaveHappenedOnceExactly();
+ }
+
+ [Fact]
+ public async Task GetLinkInfoAsync_ReturnsLinkInfoFromApiResponse()
+ {
+ // Arrange
+ var homeMaticApi = A.Fake();
+ var homeMaticIpApi = A.Fake();
+ A.CallTo(() => homeMaticApi.GetLinkInfoRawAsync(SenderAddress, ReceiverAddress))
+ .Returns(Task.FromResult>(["the name", "the description"]));
+
+ var client = CreateClient(homeMaticApi, homeMaticIpApi);
+
+ // Act
+ var info = await client.GetLinkInfoAsync(SenderAddress, ReceiverAddress);
+
+ // Assert
+ info.Name.Should().Be("the name");
+ info.Description.Should().Be("the description");
+ }
+
+ [Theory]
+ [InlineData(null, "BIDCOS:2")]
+ [InlineData("", "BIDCOS:2")]
+ [InlineData("BIDCOS:1", null)]
+ [InlineData("BIDCOS:1", "")]
+ public async Task RemoveLinkAsync_NullOrWhitespaceAddress_ThrowsAndDoesNotCallApi(string? sender,
+ string? receiver)
+ {
+ // Arrange
+ var homeMaticApi = A.Fake();
+ var homeMaticIpApi = A.Fake();
+ var client = CreateClient(homeMaticApi, homeMaticIpApi);
+
+ // Act
+ var act = () => client.RemoveLinkAsync(sender!, receiver!);
+
+ // Assert
+ await act.Should().ThrowAsync();
+ A.CallTo(homeMaticApi).MustNotHaveHappened();
+ }
+
+ [Fact]
+ public async Task SetLinkInfoAsync_NullName_ThrowsAndDoesNotCallApi()
+ {
+ // Arrange
+ var homeMaticApi = A.Fake();
+ var homeMaticIpApi = A.Fake();
+ var client = CreateClient(homeMaticApi, homeMaticIpApi);
+
+ // Act
+ var act = () => client.SetLinkInfoAsync(SenderAddress, ReceiverAddress, null!, "d");
+
+ // Assert
+ await act.Should().ThrowAsync();
+ A.CallTo(homeMaticApi).MustNotHaveHappened();
+ }
+
+ [Fact]
+ public async Task GetLinkInfoAsync_NullSender_ThrowsAndDoesNotCallApi()
+ {
+ // Arrange
+ var homeMaticApi = A.Fake();
+ var homeMaticIpApi = A.Fake();
+ var client = CreateClient(homeMaticApi, homeMaticIpApi);
+
+ // Act
+ var act = () => client.GetLinkInfoAsync(null!, ReceiverAddress);
+
+ // Assert
+ await act.Should().ThrowAsync();
+ A.CallTo(homeMaticApi).MustNotHaveHappened();
+ }
+
+ [Fact]
+ public async Task LinkOperation_UnknownDeviceKind_ThrowsKeyNotFound()
+ {
+ // Arrange
+ var homeMaticApi = A.Fake();
+ var jsonRpcClient = A.Fake();
+ var connection = new XmlRpcApiConnection(
+ new XmlRpcApiAddress(new Uri("http://example.com"), CcuDeviceKind.HomeMatic),
+ homeMaticApi);
+ var xmlRpcApis = new Dictionary
+ {
+ { CcuDeviceKind.HomeMatic, connection }
+ };
+ var client = new CcuClient(jsonRpcClient, xmlRpcApis,
+ A.Fake());
+
+ // Act
+ var act = () => client.GetAllLinksAsync(CcuDeviceKind.HomeMaticIp);
+
+ // Assert
+ await act.Should().ThrowAsync();
+ }
+
+ private static CcuClient CreateClient(IHomeMaticXmlRpcApi homeMaticApi,
+ IHomeMaticXmlRpcApi homeMaticIpApi)
+ {
+ var jsonRpcClient = A.Fake();
+ var homeMaticConnection = new XmlRpcApiConnection(
+ new XmlRpcApiAddress(new Uri("http://example.com"), CcuDeviceKind.HomeMatic),
+ homeMaticApi);
+ var homeMaticIpConnection = new XmlRpcApiConnection(
+ new XmlRpcApiAddress(new Uri("http://example.com"), CcuDeviceKind.HomeMaticIp),
+ homeMaticIpApi);
+
+ var xmlRpcApis = new Dictionary
+ {
+ { CcuDeviceKind.HomeMatic, homeMaticConnection },
+ { CcuDeviceKind.HomeMaticIp, homeMaticIpConnection }
+ };
+
+ return new CcuClient(jsonRpcClient, xmlRpcApis, A.Fake());
+ }
+}
diff --git a/tests/CreativeCoders.HomeMatic.Tests/CcuClientTests.cs b/tests/CreativeCoders.HomeMatic.Tests/CcuClientTests.cs
index 1da518c..60ebebe 100644
--- a/tests/CreativeCoders.HomeMatic.Tests/CcuClientTests.cs
+++ b/tests/CreativeCoders.HomeMatic.Tests/CcuClientTests.cs
@@ -489,7 +489,7 @@ public async Task GetCompleteDevicesAsync_BuilderThrows_PropagatesException()
var ccuClient = CreateCcuClient(jsonRpcClient, homeMaticXmlRpcApi, homeMaticIpXmlRpcApi, completeBuilder);
// Act
- var act = ccuClient.GetCompleteDevicesAsync;
+ var act = () => ccuClient.GetCompleteDevicesAsync();
// Assert
await act.Should().ThrowAsync().WithMessage("boom");
diff --git a/tests/CreativeCoders.HomeMatic.Tests/CcuDeviceChannelLinkTests.cs b/tests/CreativeCoders.HomeMatic.Tests/CcuDeviceChannelLinkTests.cs
new file mode 100644
index 0000000..970f40a
--- /dev/null
+++ b/tests/CreativeCoders.HomeMatic.Tests/CcuDeviceChannelLinkTests.cs
@@ -0,0 +1,327 @@
+using CreativeCoders.HomeMatic.Core;
+using CreativeCoders.HomeMatic.Core.Devices;
+using CreativeCoders.HomeMatic.XmlRpc;
+using CreativeCoders.HomeMatic.XmlRpc.Client;
+using CreativeCoders.HomeMatic.XmlRpc.Devices;
+using CreativeCoders.HomeMatic.XmlRpc.Links;
+using FakeItEasy;
+using AwesomeAssertions;
+
+namespace CreativeCoders.HomeMatic.Tests;
+
+public class CcuDeviceChannelLinkTests
+{
+ private const string ChannelAddress = "BIDCOS:1";
+ private const string ReceiverAddress = "BIDCOS:2";
+
+ [Fact]
+ public async Task GetLinksAsync_WithDefaultFlags_PassesChannelAddressAndZeroFlagsToApi()
+ {
+ // Arrange
+ var api = A.Fake();
+ var expected = new[] { new Link { Sender = ChannelAddress, Receiver = ReceiverAddress } };
+ A.CallTo(() => api.GetLinksAsync(ChannelAddress, (int)GetLinksFlags.None))
+ .Returns(Task.FromResult>(expected));
+
+ var channel = CreateChannel(api);
+
+ // Act
+ var result = await channel.GetLinksAsync();
+
+ // Assert
+ result.Should().BeEquivalentTo(expected);
+ A.CallTo(() => api.GetLinksAsync(ChannelAddress, (int)GetLinksFlags.None))
+ .MustHaveHappenedOnceExactly();
+ }
+
+ [Fact]
+ public async Task GetLinksAsync_WithFlags_ForwardsFlagsAsIntToApi()
+ {
+ // Arrange
+ var api = A.Fake();
+ const GetLinksFlags flags = GetLinksFlags.Group | GetLinksFlags.SenderParamSet;
+ A.CallTo(() => api.GetLinksAsync(ChannelAddress, (int)flags))
+ .Returns(Task.FromResult>([]));
+
+ var channel = CreateChannel(api);
+
+ // Act
+ await channel.GetLinksAsync(flags);
+
+ // Assert
+ A.CallTo(() => api.GetLinksAsync(ChannelAddress, (int)flags))
+ .MustHaveHappenedOnceExactly();
+ }
+
+ [Fact]
+ public async Task GetLinkPeersAsync_PassesChannelAddressToApi()
+ {
+ // Arrange
+ var api = A.Fake();
+ var peers = new[] { ReceiverAddress, "BIDCOS:3" };
+ A.CallTo(() => api.GetLinkPeersAsync(ChannelAddress))
+ .Returns(Task.FromResult>(peers));
+
+ var channel = CreateChannel(api);
+
+ // Act
+ var result = await channel.GetLinkPeersAsync();
+
+ // Assert
+ result.Should().BeEquivalentTo(peers);
+ }
+
+ [Fact]
+ public async Task AddLinkToAsync_DelegatesToApiUsingChannelAddressAsSender()
+ {
+ // Arrange
+ var api = A.Fake();
+ var channel = CreateChannel(api);
+
+ // Act
+ await channel.AddLinkToAsync(ReceiverAddress, "name", "description");
+
+ // Assert
+ A.CallTo(() => api.AddLinkAsync(ChannelAddress, ReceiverAddress, "name", "description"))
+ .MustHaveHappenedOnceExactly();
+ }
+
+ [Fact]
+ public async Task AddLinkToAsync_WithoutNameAndDescription_DefaultsToEmptyStrings()
+ {
+ // Arrange
+ var api = A.Fake();
+ var channel = CreateChannel(api);
+
+ // Act
+ await channel.AddLinkToAsync(ReceiverAddress);
+
+ // Assert
+ A.CallTo(() => api.AddLinkAsync(ChannelAddress, ReceiverAddress, string.Empty, string.Empty))
+ .MustHaveHappenedOnceExactly();
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public async Task AddLinkToAsync_NullOrWhitespaceReceiver_ThrowsAndDoesNotCallApi(string? receiver)
+ {
+ // Arrange
+ var api = A.Fake();
+ var channel = CreateChannel(api);
+
+ // Act
+ var act = () => channel.AddLinkToAsync(receiver!);
+
+ // Assert
+ await act.Should().ThrowAsync();
+ A.CallTo(api).MustNotHaveHappened();
+ }
+
+ [Fact]
+ public async Task AddLinkToAsync_NullName_ThrowsAndDoesNotCallApi()
+ {
+ // Arrange
+ var api = A.Fake();
+ var channel = CreateChannel(api);
+
+ // Act
+ var act = () => channel.AddLinkToAsync(ReceiverAddress, null!, "desc");
+
+ // Assert
+ await act.Should().ThrowAsync();
+ A.CallTo(api).MustNotHaveHappened();
+ }
+
+ [Fact]
+ public async Task AddLinkToAsync_NullDescription_ThrowsAndDoesNotCallApi()
+ {
+ // Arrange
+ var api = A.Fake();
+ var channel = CreateChannel(api);
+
+ // Act
+ var act = () => channel.AddLinkToAsync(ReceiverAddress, "name", null!);
+
+ // Assert
+ await act.Should().ThrowAsync();
+ A.CallTo(api).MustNotHaveHappened();
+ }
+
+ [Fact]
+ public async Task RemoveLinkToAsync_DelegatesToApiUsingChannelAddressAsSender()
+ {
+ // Arrange
+ var api = A.Fake();
+ var channel = CreateChannel(api);
+
+ // Act
+ await channel.RemoveLinkToAsync(ReceiverAddress);
+
+ // Assert
+ A.CallTo(() => api.RemoveLinkAsync(ChannelAddress, ReceiverAddress))
+ .MustHaveHappenedOnceExactly();
+ }
+
+ [Fact]
+ public async Task RemoveLinkToAsync_NullReceiver_ThrowsAndDoesNotCallApi()
+ {
+ // Arrange
+ var api = A.Fake();
+ var channel = CreateChannel(api);
+
+ // Act
+ var act = () => channel.RemoveLinkToAsync(null!);
+
+ // Assert
+ await act.Should().ThrowAsync();
+ A.CallTo(api).MustNotHaveHappened();
+ }
+
+ [Fact]
+ public async Task SetLinkInfoAsync_NullReceiver_ThrowsAndDoesNotCallApi()
+ {
+ // Arrange
+ var api = A.Fake();
+ var channel = CreateChannel(api);
+
+ // Act
+ var act = () => channel.SetLinkInfoAsync(null!, "n", "d");
+
+ // Assert
+ await act.Should().ThrowAsync();
+ A.CallTo(api).MustNotHaveHappened();
+ }
+
+ [Fact]
+ public async Task SetLinkInfoAsync_NullName_ThrowsAndDoesNotCallApi()
+ {
+ // Arrange
+ var api = A.Fake();
+ var channel = CreateChannel(api);
+
+ // Act
+ var act = () => channel.SetLinkInfoAsync(ReceiverAddress, null!, "d");
+
+ // Assert
+ await act.Should().ThrowAsync();
+ A.CallTo(api).MustNotHaveHappened();
+ }
+
+ [Fact]
+ public async Task GetLinkInfoAsync_NullReceiver_ThrowsAndDoesNotCallApi()
+ {
+ // Arrange
+ var api = A.Fake();
+ var channel = CreateChannel(api);
+
+ // Act
+ var act = () => channel.GetLinkInfoAsync(null!);
+
+ // Assert
+ await act.Should().ThrowAsync();
+ A.CallTo(api).MustNotHaveHappened();
+ }
+
+ [Fact]
+ public async Task GetLinksAsync_ApiThrows_ExceptionPropagates()
+ {
+ // Arrange
+ var api = A.Fake();
+ A.CallTo(() => api.GetLinksAsync(A._, A._))
+ .Throws(new InvalidOperationException("boom"));
+ var channel = CreateChannel(api);
+
+ // Act
+ var act = () => channel.GetLinksAsync();
+
+ // Assert
+ await act.Should().ThrowAsync().WithMessage("boom");
+ }
+
+ [Fact]
+ public async Task SetLinkInfoAsync_DelegatesToApi()
+ {
+ // Arrange
+ var api = A.Fake();
+ var channel = CreateChannel(api);
+
+ // Act
+ await channel.SetLinkInfoAsync(ReceiverAddress, "new name", "new description");
+
+ // Assert
+ A.CallTo(() => api.SetLinkInfoAsync(ChannelAddress, ReceiverAddress, "new name", "new description"))
+ .MustHaveHappenedOnceExactly();
+ }
+
+ [Fact]
+ public async Task GetLinkInfoAsync_ReturnsLinkInfoFromApiResponse()
+ {
+ // Arrange
+ var api = A.Fake();
+ A.CallTo(() => api.GetLinkInfoRawAsync(ChannelAddress, ReceiverAddress))
+ .Returns(Task.FromResult>(["the name", "the description"]));
+
+ var channel = CreateChannel(api);
+
+ // Act
+ var info = await channel.GetLinkInfoAsync(ReceiverAddress);
+
+ // Assert
+ info.Name.Should().Be("the name");
+ info.Description.Should().Be("the description");
+ }
+
+ [Fact]
+ public async Task ActivateLinkParamsetAsync_DelegatesToApi()
+ {
+ // Arrange
+ var api = A.Fake();
+ var channel = CreateChannel(api);
+
+ // Act
+ await channel.ActivateLinkParamsetAsync(ReceiverAddress, longPress: true);
+
+ // Assert
+ A.CallTo(() => api.ActivateLinkParamsetAsync(ChannelAddress, ReceiverAddress, true))
+ .MustHaveHappenedOnceExactly();
+ }
+
+ [Fact]
+ public async Task ActivateLinkParamsetAsync_NullPeer_ThrowsAndDoesNotCallApi()
+ {
+ // Arrange
+ var api = A.Fake();
+ var channel = CreateChannel(api);
+
+ // Act
+ var act = () => channel.ActivateLinkParamsetAsync(null!, longPress: false);
+
+ // Assert
+ await act.Should().ThrowAsync();
+ A.CallTo(api).MustNotHaveHappened();
+ }
+
+ private static CcuDeviceChannel CreateChannel(IHomeMaticXmlRpcApi api)
+ {
+ return new CcuDeviceChannel(api)
+ {
+ Uri = new CcuDeviceUri
+ {
+ CcuHost = "localhost",
+ Kind = CcuDeviceKind.HomeMatic,
+ Address = ChannelAddress
+ },
+ DeviceType = "TestChannel",
+ IsAesActive = false,
+ Interface = "BidCos-RF",
+ Version = 1,
+ Roaming = false,
+ ParamSets = ["MASTER", "VALUES"],
+ Index = 1,
+ Group = string.Empty,
+ ChannelDirection = ChannelDirection.None
+ };
+ }
+}
diff --git a/tests/CreativeCoders.HomeMatic.Tests/CompleteCcuDeviceBuilderTests.cs b/tests/CreativeCoders.HomeMatic.Tests/CompleteCcuDeviceBuilderTests.cs
index 6774b05..f56484b 100644
--- a/tests/CreativeCoders.HomeMatic.Tests/CompleteCcuDeviceBuilderTests.cs
+++ b/tests/CreativeCoders.HomeMatic.Tests/CompleteCcuDeviceBuilderTests.cs
@@ -1,4 +1,6 @@
+using CreativeCoders.HomeMatic.Core;
using CreativeCoders.HomeMatic.Core.Devices;
+using CreativeCoders.HomeMatic.XmlRpc.Links;
using FakeItEasy;
using AwesomeAssertions;
@@ -181,6 +183,84 @@ public async Task BuildAsync_ChannelWithoutParamSets_ReturnsChannelWithEmptyPara
.Which.ParamSetValues.Should().BeEmpty();
}
+ [Fact]
+ public async Task BuildAsync_WithoutOptions_DoesNotFetchLinks()
+ {
+ // 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([]);
+
+ var builder = new CompleteCcuDeviceBuilder();
+
+ // Act
+ var completeDevice = await builder.BuildAsync(device);
+
+ // Assert
+ A.CallTo(channel)
+ .Where(call => call.Method.Name == nameof(ICcuDeviceChannel.GetLinksAsync))
+ .MustNotHaveHappened();
+ completeDevice.Channels.Single().Links.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task BuildAsync_WithIncludeLinks_FetchesLinksWithRequestedFlags()
+ {
+ // 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([]);
+
+ var expectedLink = new Link { Sender = "X:1", Receiver = "Y:1", Name = "n", Description = "d" };
+ const GetLinksFlags expectedFlags = GetLinksFlags.SenderParamSet;
+ A.CallTo(() => channel.GetLinksAsync(expectedFlags))
+ .Returns(Task.FromResult>([expectedLink]));
+
+ var builder = new CompleteCcuDeviceBuilder();
+ var options = new CompleteCcuDeviceBuildOptions
+ {
+ IncludeLinks = true,
+ LinksFlags = expectedFlags
+ };
+
+ // Act
+ var completeDevice = await builder.BuildAsync(device, options);
+
+ // Assert
+ A.CallTo(() => channel.GetLinksAsync(expectedFlags)).MustHaveHappenedOnceExactly();
+ completeDevice.Channels.Single().Links.Should().ContainSingle()
+ .Which.Should().BeEquivalentTo(expectedLink);
+ }
+
+ [Fact]
+ public async Task BuildAsync_WithIncludeLinksFalse_DoesNotFetchLinks()
+ {
+ // 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([]);
+
+ var builder = new CompleteCcuDeviceBuilder();
+ var options = new CompleteCcuDeviceBuildOptions { IncludeLinks = false };
+
+ // Act
+ await builder.BuildAsync(device, options);
+
+ // Assert
+ A.CallTo(channel)
+ .Where(call => call.Method.Name == nameof(ICcuDeviceChannel.GetLinksAsync))
+ .MustNotHaveHappened();
+ }
+
private static void SetupParamSet(ICcuDeviceBase device, string paramSetKey, string name, object value)
{
A.CallTo(() => device.GetParamSetValuesAsync(paramSetKey))
diff --git a/tests/CreativeCoders.HomeMatic.Tests/Exporting/CompleteCcuDeviceChannelFakeBuilder.cs b/tests/CreativeCoders.HomeMatic.Tests/Exporting/CompleteCcuDeviceChannelFakeBuilder.cs
index 3b9a9a7..235722e 100644
--- a/tests/CreativeCoders.HomeMatic.Tests/Exporting/CompleteCcuDeviceChannelFakeBuilder.cs
+++ b/tests/CreativeCoders.HomeMatic.Tests/Exporting/CompleteCcuDeviceChannelFakeBuilder.cs
@@ -1,6 +1,7 @@
using CreativeCoders.HomeMatic.Core;
using CreativeCoders.HomeMatic.Core.Devices;
using CreativeCoders.HomeMatic.XmlRpc;
+using CreativeCoders.HomeMatic.XmlRpc.Links;
using FakeItEasy;
namespace CreativeCoders.HomeMatic.Tests.Exporting;
@@ -12,8 +13,15 @@ internal sealed class CompleteCcuDeviceChannelFakeBuilder
private int _index = 1;
private string[] _paramSets = ["VALUES"];
private readonly List _paramSetValues = [];
+ private readonly List _links = [];
private string _ccuHost = "ccu2.local";
+ public CompleteCcuDeviceChannelFakeBuilder WithLink(Link link)
+ {
+ _links.Add(link);
+ return this;
+ }
+
public CompleteCcuDeviceChannelFakeBuilder WithAddress(string address)
{
_address = address;
@@ -61,7 +69,7 @@ public CompleteCcuDeviceChannelFakeBuilder WithParamSet(string paramSetKey, Acti
public ICompleteCcuDeviceChannel Build()
{
var channel = A.Fake();
- var channelData = A.Fake();
+ var channelData = A.Fake();
var uri = new CcuDeviceUri
{
@@ -77,6 +85,7 @@ public ICompleteCcuDeviceChannel Build()
A.CallTo(() => channel.ChannelData).Returns(channelData);
A.CallTo(() => channel.ParamSetValues).Returns(_paramSetValues);
+ A.CallTo(() => channel.Links).Returns(_links);
return channel;
}
diff --git a/tests/CreativeCoders.HomeMatic.Tests/Exporting/DeviceExporterTests.cs b/tests/CreativeCoders.HomeMatic.Tests/Exporting/DeviceExporterTests.cs
index ea8111a..b68d801 100644
--- a/tests/CreativeCoders.HomeMatic.Tests/Exporting/DeviceExporterTests.cs
+++ b/tests/CreativeCoders.HomeMatic.Tests/Exporting/DeviceExporterTests.cs
@@ -1,6 +1,7 @@
using System.Text.Json;
using AwesomeAssertions;
using CreativeCoders.HomeMatic.Exporting;
+using CreativeCoders.HomeMatic.XmlRpc.Links;
namespace CreativeCoders.HomeMatic.Tests.Exporting;
@@ -703,4 +704,157 @@ public async Task ExportDevicesAsync_WithOptions_AppliesFilterToEachDevice()
paramSet.ParamSetKey.Should().Be("MASTER");
paramSet.Values.Select(v => v.Key).Should().BeEquivalentTo("BOOST_TIME");
}
+
+ // ---- Links ------------------------------------------------------------
+
+ [Fact]
+ public void BuildExportData_WithoutIncludeLinks_LinksAreNull()
+ {
+ // Arrange
+ var device = new CompleteCcuDeviceFakeBuilder()
+ .WithChannel(c => c
+ .WithAddress("XYZ:1")
+ .WithLink(new Link { Sender = "XYZ:1", Receiver = "ABC:2", Name = "n", Description = "d" }))
+ .Build();
+ var sut = new DeviceExporter();
+
+ // Act
+ var result = sut.BuildExportData(device);
+
+ // Assert
+ result.Channels.Single().Links.Should().BeNull();
+ }
+
+ [Fact]
+ public void BuildExportData_WithIncludeLinksButNoLinks_ReturnsEmptyList()
+ {
+ // Arrange
+ var device = new CompleteCcuDeviceFakeBuilder()
+ .WithChannel(c => c.WithAddress("XYZ:1"))
+ .Build();
+ var options = new DeviceExportOptions { IncludeLinks = true };
+ var sut = new DeviceExporter();
+
+ // Act
+ var result = sut.BuildExportData(device, options);
+
+ // Assert
+ result.Channels.Single().Links.Should().NotBeNull();
+ result.Channels.Single().Links.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void BuildExportData_WithIncludeLinksAndLinks_MapsAllLinkFields()
+ {
+ // Arrange
+ var link = new Link
+ {
+ Sender = "XYZ:1",
+ Receiver = "ABC:2",
+ Name = "MyLink",
+ Description = "MyDescription"
+ };
+ var device = new CompleteCcuDeviceFakeBuilder()
+ .WithChannel(c => c.WithAddress("XYZ:1").WithLink(link))
+ .Build();
+ var options = new DeviceExportOptions { IncludeLinks = true };
+ var sut = new DeviceExporter();
+
+ // Act
+ var result = sut.BuildExportData(device, options);
+
+ // Assert
+ var exportedLink = result.Channels.Single().Links.Should().ContainSingle().Subject;
+ exportedLink.Sender.Should().Be("XYZ:1");
+ exportedLink.Receiver.Should().Be("ABC:2");
+ exportedLink.Name.Should().Be("MyLink");
+ exportedLink.Description.Should().Be("MyDescription");
+ }
+
+ [Fact]
+ public void BuildExportData_WithIncludeLinksAndMultipleLinks_PreservesOrder()
+ {
+ // Arrange
+ var device = new CompleteCcuDeviceFakeBuilder()
+ .WithChannel(c => c
+ .WithAddress("XYZ:1")
+ .WithLink(new Link { Sender = "XYZ:1", Receiver = "A:1", Name = "1", Description = "" })
+ .WithLink(new Link { Sender = "XYZ:1", Receiver = "B:1", Name = "2", Description = "" })
+ .WithLink(new Link { Sender = "XYZ:1", Receiver = "C:1", Name = "3", Description = "" }))
+ .Build();
+ var options = new DeviceExportOptions { IncludeLinks = true };
+ var sut = new DeviceExporter();
+
+ // Act
+ var result = sut.BuildExportData(device, options);
+
+ // Assert
+ result.Channels.Single().Links!.Select(l => l.Receiver)
+ .Should().ContainInOrder("A:1", "B:1", "C:1");
+ }
+
+ [Fact]
+ public async Task ExportDeviceAsync_WithIncludeLinks_EmitsLinksArrayInJson()
+ {
+ // Arrange
+ var device = new CompleteCcuDeviceFakeBuilder()
+ .WithChannel(c => c
+ .WithAddress("XYZ:1")
+ .WithLink(new Link { Sender = "XYZ:1", Receiver = "ABC:2", Name = "n", Description = "d" }))
+ .Build();
+ var options = new DeviceExportOptions { IncludeLinks = true, WriteIndented = false };
+ var sut = new DeviceExporter();
+
+ // Act
+ var json = await sut.ExportDeviceAsync(device, options);
+
+ // Assert
+ using var document = JsonDocument.Parse(json);
+ var channel = document.RootElement.GetProperty("channels")[0];
+ channel.TryGetProperty("links", out var links).Should().BeTrue();
+ links.GetArrayLength().Should().Be(1);
+ var first = links[0];
+ first.GetProperty("sender").GetString().Should().Be("XYZ:1");
+ first.GetProperty("receiver").GetString().Should().Be("ABC:2");
+ first.GetProperty("name").GetString().Should().Be("n");
+ first.GetProperty("description").GetString().Should().Be("d");
+ }
+
+ [Fact]
+ public async Task ExportDeviceAsync_WithoutIncludeLinks_OmitsLinksFromJson()
+ {
+ // Arrange
+ var device = new CompleteCcuDeviceFakeBuilder()
+ .WithChannel(c => c
+ .WithAddress("XYZ:1")
+ .WithLink(new Link { Sender = "XYZ:1", Receiver = "ABC:2", Name = "n", Description = "d" }))
+ .Build();
+ var sut = new DeviceExporter();
+
+ // Act
+ var json = await sut.ExportDeviceAsync(device);
+
+ // Assert
+ using var document = JsonDocument.Parse(json);
+ var channel = document.RootElement.GetProperty("channels")[0];
+ channel.TryGetProperty("links", out _).Should().BeFalse();
+ }
+
+ [Fact]
+ public void DeviceExportOptions_ToBuildOptions_PropagatesIncludeLinksAndFlags()
+ {
+ // Arrange
+ var options = new DeviceExportOptions
+ {
+ IncludeLinks = true,
+ LinksFlags = GetLinksFlags.SenderParamSet | GetLinksFlags.ReceiverParamSet
+ };
+
+ // Act
+ var buildOptions = options.ToBuildOptions();
+
+ // Assert
+ buildOptions.IncludeLinks.Should().BeTrue();
+ buildOptions.LinksFlags.Should().Be(GetLinksFlags.SenderParamSet | GetLinksFlags.ReceiverParamSet);
+ }
}
diff --git a/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/DataOutputCommandBaseAdditionalTests.cs b/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/DataOutputCommandBaseAdditionalTests.cs
new file mode 100644
index 0000000..4229c07
--- /dev/null
+++ b/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/DataOutputCommandBaseAdditionalTests.cs
@@ -0,0 +1,102 @@
+using AwesomeAssertions;
+using CreativeCoders.HomeMatic.Tools.Cli.Base.Commands;
+using CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Output;
+using CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Serialization;
+using FakeItEasy;
+using Spectre.Console;
+
+namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Tests.Commands;
+
+public class DataOutputCommandBaseAdditionalTests
+{
+ private sealed class TestOptions : IDataOutputOptions
+ {
+ public DataOutputFormat OutputFormat { get; init; } = DataOutputFormat.Auto;
+
+ public string? OutputFile { get; init; }
+ }
+
+ private sealed class ConfigurableCommand : DataOutputCommandBase
+ {
+ public ConfigurableCommand(
+ IAnsiConsole console,
+ IDataSerializerFactory factory,
+ IDataOutputWriter writer)
+ : base(console, factory, writer)
+ {
+ }
+
+ public Func>? LoadFunc { get; init; }
+
+ public Func? TransformFunc { get; init; }
+
+ protected override Task LoadDataAsync(TestOptions options)
+ => LoadFunc is null ? Task.FromResult("data") : LoadFunc(options);
+
+ protected override object TransformData(string data, TestOptions options)
+ => TransformFunc is null ? data : TransformFunc(data, options)!;
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_WhenLoadDataThrows_ExceptionPropagates()
+ {
+ // Arrange
+ var sut = new ConfigurableCommand(
+ A.Fake(),
+ A.Fake(),
+ A.Fake())
+ {
+ LoadFunc = _ => throw new InvalidOperationException("boom")
+ };
+
+ // Act
+ var act = async () => await sut.ExecuteAsync(new TestOptions());
+
+ // Assert
+ await act.Should().ThrowAsync().WithMessage("boom");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_WhenTransformReturnsNull_Throws()
+ {
+ // Arrange
+ var sut = new ConfigurableCommand(
+ A.Fake(),
+ A.Fake(),
+ A.Fake())
+ {
+ TransformFunc = (_, _) => null
+ };
+
+ // Act
+ var act = async () => await sut.ExecuteAsync(new TestOptions());
+
+ // Assert
+ await act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_WhenWriterThrows_ExceptionPropagates()
+ {
+ // Arrange
+ var serializer = A.Fake();
+ A.CallTo(() => serializer.Serialize(A