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._)).Returns("payload"); + + var factory = A.Fake(); + A.CallTo(() => factory.Create(A._)).Returns(serializer); + + var writer = A.Fake(); + A.CallTo(() => writer.ResolveFormat(A._, A._)) + .Returns(DataOutputFormat.Json); + A.CallTo(() => writer.WriteAsync(A._, A._)) + .ThrowsAsync(new IOException("write failed")); + + var sut = new ConfigurableCommand(A.Fake(), factory, writer); + + // Act + var act = async () => await sut.ExecuteAsync(new TestOptions()); + + // Assert + await act.Should().ThrowAsync().WithMessage("write failed"); + } +} diff --git a/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/DataOutputCommandBaseTests.cs b/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/DataOutputCommandBaseTests.cs new file mode 100644 index 0000000..3e37fd0 --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/DataOutputCommandBaseTests.cs @@ -0,0 +1,196 @@ +using AwesomeAssertions; +using CreativeCoders.Cli.Core; +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 DataOutputCommandBaseTests +{ + private sealed class TestOptions : IDataOutputOptions + { + public DataOutputFormat OutputFormat { get; init; } = DataOutputFormat.Auto; + + public string? OutputFile { get; init; } + } + + private sealed class TestCommand : DataOutputCommandBase + { + private readonly string _data; + + public TestCommand( + IAnsiConsole console, + IDataSerializerFactory factory, + IDataOutputWriter writer, + string data) + : base(console, factory, writer) + { + _data = data; + } + + public int LoadCount { get; private set; } + + public int TransformCount { get; private set; } + + public int BeforeLoadCount { get; private set; } + + public int BeforeWriteCount { get; private set; } + + public int AfterWriteCount { get; private set; } + + public DataOutputFormat? LastResolvedFormat { get; private set; } + + protected override Task LoadDataAsync(TestOptions options) + { + LoadCount++; + + return Task.FromResult(_data); + } + + protected override object TransformData(string data, TestOptions options) + { + TransformCount++; + + return new { Wrapped = data }; + } + + protected override Task OnBeforeLoadAsync(TestOptions options) + { + BeforeLoadCount++; + + return Task.CompletedTask; + } + + protected override Task OnBeforeWriteAsync(TestOptions options, DataOutputFormat resolvedFormat) + { + BeforeWriteCount++; + LastResolvedFormat = resolvedFormat; + + return Task.CompletedTask; + } + + protected override Task OnAfterWriteAsync(TestOptions options, DataOutputFormat resolvedFormat) + { + AfterWriteCount++; + + return Task.CompletedTask; + } + } + + private static (TestCommand Sut, IDataSerializer Serializer, IDataSerializerFactory Factory, + IDataOutputWriter Writer) CreateSut( + string serialized, + DataOutputFormat resolvedFormat, + string data = "payload") + { + var console = A.Fake(); + var serializer = A.Fake(); + A.CallTo(() => serializer.Serialize(A._)).Returns(serialized); + + var factory = A.Fake(); + A.CallTo(() => factory.Create(A._)).Returns(serializer); + + var writer = A.Fake(); + A.CallTo(() => writer.ResolveFormat(A._, A._)) + .Returns(resolvedFormat); + + var sut = new TestCommand(console, factory, writer, data); + + return (sut, serializer, factory, writer); + } + + [Fact] + public async Task ExecuteAsync_OrchestratesLoadTransformSerializeAndWrite() + { + // Arrange + var (sut, serializer, factory, writer) = CreateSut("serialized", DataOutputFormat.Yaml); + var options = new TestOptions { OutputFormat = DataOutputFormat.Auto, OutputFile = "out.yaml" }; + + // Act + var result = await sut.ExecuteAsync(options); + + // Assert + result.Should().Be(CommandResult.Success); + sut.LoadCount.Should().Be(1); + sut.TransformCount.Should().Be(1); + A.CallTo(() => writer.ResolveFormat(DataOutputFormat.Auto, "out.yaml")).MustHaveHappenedOnceExactly(); + A.CallTo(() => factory.Create(DataOutputFormat.Yaml)).MustHaveHappenedOnceExactly(); + A.CallTo(() => serializer.Serialize(A._)).MustHaveHappenedOnceExactly(); + A.CallTo(() => writer.WriteAsync("serialized", "out.yaml")).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task ExecuteAsync_InvokesAllHooks() + { + // Arrange + var (sut, _, _, _) = CreateSut("serialized", DataOutputFormat.Json); + var options = new TestOptions(); + + // Act + await sut.ExecuteAsync(options); + + // Assert + sut.BeforeLoadCount.Should().Be(1); + sut.BeforeWriteCount.Should().Be(1); + sut.AfterWriteCount.Should().Be(1); + sut.LastResolvedFormat.Should().Be(DataOutputFormat.Json); + } + + [Fact] + public async Task ExecuteAsync_WithNullOptions_Throws() + { + // Arrange + var (sut, _, _, _) = CreateSut("serialized", DataOutputFormat.Json); + + // Act + var act = async () => await sut.ExecuteAsync(null!); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public void Constructor_WithNullConsole_Throws() + { + // Arrange + var factory = A.Fake(); + var writer = A.Fake(); + + // Act + var act = () => new TestCommand(null!, factory, writer, "x"); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Constructor_WithNullFactory_Throws() + { + // Arrange + var console = A.Fake(); + var writer = A.Fake(); + + // Act + var act = () => new TestCommand(console, null!, writer, "x"); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Constructor_WithNullWriter_Throws() + { + // Arrange + var console = A.Fake(); + var factory = A.Fake(); + + // Act + var act = () => new TestCommand(console, factory, null!, "x"); + + // Assert + act.Should().Throw(); + } +} diff --git a/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/Output/DataOutputWriterTests.cs b/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/Output/DataOutputWriterTests.cs new file mode 100644 index 0000000..6c4c2df --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/Output/DataOutputWriterTests.cs @@ -0,0 +1,194 @@ +using AwesomeAssertions; +using CreativeCoders.HomeMatic.Tools.Cli.Base.Commands; +using CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Output; +using FakeItEasy; +using Spectre.Console; + +namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Tests.Commands.Output; + +public class DataOutputWriterTests +{ + [Theory] + [InlineData("output.json", DataOutputFormat.Json)] + [InlineData("Output.JSON", DataOutputFormat.Json)] + [InlineData("/tmp/data.yaml", DataOutputFormat.Yaml)] + [InlineData("data.YML", DataOutputFormat.Yaml)] + public void ResolveFormat_WhenFileExtensionKnown_ReturnsFormatFromExtension( + string outputFile, DataOutputFormat expected) + { + // Arrange + var sut = new DataOutputWriter(A.Fake()); + + // Act + var result = sut.ResolveFormat(DataOutputFormat.Json, outputFile); + + // Assert + result.Should().Be(expected); + } + + [Fact] + public void ResolveFormat_WhenFileExtensionKnown_OverridesRequestedFormat() + { + // Arrange + var sut = new DataOutputWriter(A.Fake()); + + // Act + var result = sut.ResolveFormat(DataOutputFormat.Json, "result.yaml"); + + // Assert + result.Should().Be(DataOutputFormat.Yaml); + } + + [Theory] + [InlineData(DataOutputFormat.Json, DataOutputFormat.Json)] + [InlineData(DataOutputFormat.Yaml, DataOutputFormat.Yaml)] + [InlineData(DataOutputFormat.Auto, DataOutputFormat.Json)] + public void ResolveFormat_WhenNoOutputFile_UsesRequestedOrDefault( + DataOutputFormat requested, DataOutputFormat expected) + { + // Arrange + var sut = new DataOutputWriter(A.Fake()); + + // Act + var result = sut.ResolveFormat(requested, null); + + // Assert + result.Should().Be(expected); + } + + [Fact] + public void ResolveFormat_WhenExtensionUnknown_FallsBackToRequestedFormat() + { + // Arrange + var sut = new DataOutputWriter(A.Fake()); + + // Act + var result = sut.ResolveFormat(DataOutputFormat.Yaml, "data.txt"); + + // Assert + result.Should().Be(DataOutputFormat.Yaml); + } + + [Fact] + public void ResolveFormat_WhenExtensionUnknownAndAuto_DefaultsToJson() + { + // Arrange + var sut = new DataOutputWriter(A.Fake()); + + // Act + var result = sut.ResolveFormat(DataOutputFormat.Auto, "data.txt"); + + // Assert + result.Should().Be(DataOutputFormat.Json); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task WriteAsync_WithoutOutputFile_WritesToConsole(string? outputFile) + { + // Arrange + var console = A.Fake(); + var sut = new DataOutputWriter(console); + + // Act + await sut.WriteAsync("payload", outputFile); + + // Assert + A.CallTo(console) + .Where(c => c.Method.Name == nameof(IAnsiConsole.Write)) + .MustHaveHappened(); + } + + [Fact] + public async Task WriteAsync_WithOutputFile_WritesContentToFile() + { + // Arrange + var sut = new DataOutputWriter(A.Fake()); + var path = Path.Combine(Path.GetTempPath(), $"data-output-writer-{Guid.NewGuid():N}.json"); + + try + { + // Act + await sut.WriteAsync("hello", path); + + // Assert + File.Exists(path).Should().BeTrue(); + (await File.ReadAllTextAsync(path)).Should().Be("hello"); + } + finally + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + } + + [Fact] + public async Task WriteAsync_WithNullContent_Throws() + { + // Arrange + var sut = new DataOutputWriter(A.Fake()); + + // Act + var act = async () => await sut.WriteAsync(null!, null); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public void ResolveFormat_WithEmptyOutputFile_BehavesLikeNull() + { + // Arrange + var sut = new DataOutputWriter(A.Fake()); + + // Act + var result = sut.ResolveFormat(DataOutputFormat.Yaml, string.Empty); + + // Assert + result.Should().Be(DataOutputFormat.Yaml); + } + + [Fact] + public void ResolveFormat_WithMultipleDotsInFileName_UsesLastExtension() + { + // Arrange + var sut = new DataOutputWriter(A.Fake()); + + // Act + var result = sut.ResolveFormat(DataOutputFormat.Yaml, "data.backup.json"); + + // Assert + result.Should().Be(DataOutputFormat.Json); + } + + [Fact] + public async Task WriteAsync_ToNonExistentDirectory_Throws() + { + // Arrange + var sut = new DataOutputWriter(A.Fake()); + var path = Path.Combine( + Path.GetTempPath(), + $"missing-dir-{Guid.NewGuid():N}", + "out.json"); + + // Act + var act = async () => await sut.WriteAsync("data", path); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public void Constructor_WithNullConsole_Throws() + { + // Arrange & Act + var act = () => new DataOutputWriter(null!); + + // Assert + act.Should().Throw(); + } +} diff --git a/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/Serialization/DataSerializerFactoryTests.cs b/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/Serialization/DataSerializerFactoryTests.cs new file mode 100644 index 0000000..452da94 --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/Serialization/DataSerializerFactoryTests.cs @@ -0,0 +1,96 @@ +using AwesomeAssertions; +using CreativeCoders.HomeMatic.Tools.Cli.Base.Commands; +using CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Serialization; +using FakeItEasy; + +namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Tests.Commands.Serialization; + +public class DataSerializerFactoryTests +{ + private static IDataSerializer FakeSerializer(DataOutputFormat format) + { + var serializer = A.Fake(); + A.CallTo(() => serializer.Format).Returns(format); + + return serializer; + } + + [Fact] + public void Create_WithJsonFormat_ReturnsRegisteredJsonSerializer() + { + // Arrange + var jsonSerializer = FakeSerializer(DataOutputFormat.Json); + var yamlSerializer = FakeSerializer(DataOutputFormat.Yaml); + var sut = new DataSerializerFactory([jsonSerializer, yamlSerializer]); + + // Act + var result = sut.Create(DataOutputFormat.Json); + + // Assert + result.Should().BeSameAs(jsonSerializer); + } + + [Fact] + public void Create_WithYamlFormat_ReturnsRegisteredYamlSerializer() + { + // Arrange + var jsonSerializer = FakeSerializer(DataOutputFormat.Json); + var yamlSerializer = FakeSerializer(DataOutputFormat.Yaml); + var sut = new DataSerializerFactory([jsonSerializer, yamlSerializer]); + + // Act + var result = sut.Create(DataOutputFormat.Yaml); + + // Assert + result.Should().BeSameAs(yamlSerializer); + } + + [Fact] + public void Create_WithAuto_Throws() + { + // Arrange + var sut = new DataSerializerFactory([FakeSerializer(DataOutputFormat.Json)]); + + // Act + var act = () => sut.Create(DataOutputFormat.Auto); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Create_WhenFormatNotRegistered_Throws() + { + // Arrange + var sut = new DataSerializerFactory([FakeSerializer(DataOutputFormat.Json)]); + + // Act + var act = () => sut.Create(DataOutputFormat.Yaml); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Create_WithEmptySerializers_ThrowsForAnyFormat() + { + // Arrange + var sut = new DataSerializerFactory([]); + + // Act + var act = () => sut.Create(DataOutputFormat.Json); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Constructor_WithNullSerializers_Throws() + { + // Arrange & Act + var act = () => new DataSerializerFactory(null!); + + // Assert + act.Should().Throw(); + } +} diff --git a/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/Serialization/JsonDataSerializerTests.cs b/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/Serialization/JsonDataSerializerTests.cs new file mode 100644 index 0000000..7bf4b19 --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/Serialization/JsonDataSerializerTests.cs @@ -0,0 +1,105 @@ +using AwesomeAssertions; +using CreativeCoders.HomeMatic.Tools.Cli.Base.Commands; +using CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Serialization; + +namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Tests.Commands.Serialization; + +public class JsonDataSerializerTests +{ + private sealed class SampleData + { + public string FirstName { get; set; } = string.Empty; + + public int? Age { get; set; } + + public string? OptionalNote { get; set; } + } + + [Fact] + public void Format_Returns_Json() + { + // Arrange + var sut = new JsonDataSerializer(); + + // Act + var format = sut.Format; + + // Assert + format.Should().Be(DataOutputFormat.Json); + } + + [Fact] + public void Serialize_WithObject_ProducesIndentedCamelCaseJson() + { + // Arrange + var sut = new JsonDataSerializer(); + var data = new SampleData { FirstName = "Alice", Age = 42 }; + + // Act + var result = sut.Serialize(data); + + // Assert + result.Should().Contain("\"firstName\": \"Alice\""); + result.Should().Contain("\"age\": 42"); + result.Should().Contain("\n"); + } + + [Fact] + public void Serialize_WithNullProperty_OmitsTheProperty() + { + // Arrange + var sut = new JsonDataSerializer(); + var data = new SampleData { FirstName = "Bob", Age = null, OptionalNote = null }; + + // Act + var result = sut.Serialize(data); + + // Assert + result.Should().NotContain("optionalNote"); + result.Should().NotContain("age"); + } + + [Fact] + public void Serialize_WithCollection_IncludesAllItems() + { + // Arrange + var sut = new JsonDataSerializer(); + + // Act + var result = sut.Serialize(new[] { "a", "b", "c" }); + + // Assert + result.Should().Contain("\"a\""); + result.Should().Contain("\"b\""); + result.Should().Contain("\"c\""); + } + + [Fact] + public void Serialize_WithNestedObjectContainingNullProperty_OmitsNullProperty() + { + // Arrange + var sut = new JsonDataSerializer(); + var data = new { Outer = new SampleData { FirstName = "X", Age = null, OptionalNote = null } }; + + // Act + var result = sut.Serialize(data); + + // Assert + result.Should().Contain("\"firstName\": \"X\""); + result.Should().NotContain("optionalNote"); + result.Should().NotContain("\"age\""); + } + + [Fact] + public void Serialize_WithNullData_Throws() + { + // Arrange + var sut = new JsonDataSerializer(); + + // Act + var act = () => sut.Serialize(null!); + + // Assert + act.Should().Throw(); + } +} diff --git a/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/Serialization/YamlDataSerializerTests.cs b/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/Serialization/YamlDataSerializerTests.cs new file mode 100644 index 0000000..edc1f62 --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/Commands/Serialization/YamlDataSerializerTests.cs @@ -0,0 +1,104 @@ +using AwesomeAssertions; +using CreativeCoders.HomeMatic.Tools.Cli.Base.Commands; +using CreativeCoders.HomeMatic.Tools.Cli.Base.Commands.Serialization; + +namespace CreativeCoders.HomeMatic.Tools.Cli.Base.Tests.Commands.Serialization; + +public class YamlDataSerializerTests +{ + private sealed class SampleData + { + public string FirstName { get; set; } = string.Empty; + + public int? Age { get; set; } + + public string? OptionalNote { get; set; } + } + + [Fact] + public void Format_Returns_Yaml() + { + // Arrange + var sut = new YamlDataSerializer(); + + // Act + var format = sut.Format; + + // Assert + format.Should().Be(DataOutputFormat.Yaml); + } + + [Fact] + public void Serialize_WithObject_ProducesCamelCaseYaml() + { + // Arrange + var sut = new YamlDataSerializer(); + var data = new SampleData { FirstName = "Alice", Age = 42 }; + + // Act + var result = sut.Serialize(data); + + // Assert + result.Should().Contain("firstName: Alice"); + result.Should().Contain("age: 42"); + } + + [Fact] + public void Serialize_WithNullProperty_OmitsTheProperty() + { + // Arrange + var sut = new YamlDataSerializer(); + var data = new SampleData { FirstName = "Bob", Age = null, OptionalNote = null }; + + // Act + var result = sut.Serialize(data); + + // Assert + result.Should().NotContain("optionalNote"); + result.Should().NotContain("age"); + } + + [Fact] + public void Serialize_WithCollection_IncludesAllItems() + { + // Arrange + var sut = new YamlDataSerializer(); + + // Act + var result = sut.Serialize(new[] { "a", "b", "c" }); + + // Assert + result.Should().Contain("- a"); + result.Should().Contain("- b"); + result.Should().Contain("- c"); + } + + [Fact] + public void Serialize_WithNestedObjectContainingNullProperty_OmitsNullProperty() + { + // Arrange + var sut = new YamlDataSerializer(); + var data = new { Outer = new SampleData { FirstName = "X", Age = null, OptionalNote = null } }; + + // Act + var result = sut.Serialize(data); + + // Assert + result.Should().Contain("firstName: X"); + result.Should().NotContain("optionalNote"); + result.Should().NotContain("age:"); + } + + [Fact] + public void Serialize_WithNullData_Throws() + { + // Arrange + var sut = new YamlDataSerializer(); + + // Act + var act = () => sut.Serialize(null!); + + // Assert + act.Should().Throw(); + } +} diff --git a/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests.csproj b/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests.csproj new file mode 100644 index 0000000..a627346 --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests/CreativeCoders.HomeMatic.Tools.Cli.Base.Tests.csproj @@ -0,0 +1,36 @@ + + + + enable + enable + + false + true + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + diff --git a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Client/HomeMaticXmlRpcApiBuilderTests.cs b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Client/HomeMaticXmlRpcApiBuilderTests.cs new file mode 100644 index 0000000..f6fa0ae --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Client/HomeMaticXmlRpcApiBuilderTests.cs @@ -0,0 +1,124 @@ +using System.Text; +using CreativeCoders.HomeMatic.XmlRpc.Client; +using CreativeCoders.Net.XmlRpc.Proxy; +using FakeItEasy; +using AwesomeAssertions; + +namespace CreativeCoders.HomeMatic.XmlRpc.Tests.Client; + +public class HomeMaticXmlRpcApiBuilderTests +{ + [Fact] + public void Constructor_NullProxyBuilder_ThrowsArgumentNullException() + { + // Arrange & Act + Action act = () => new HomeMaticXmlRpcApiBuilder(null!); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void ForUrl_NullUri_ThrowsArgumentNullException() + { + // Arrange + var proxyBuilder = A.Fake>(); + var sut = new HomeMaticXmlRpcApiBuilder(proxyBuilder); + + // Act + Action act = () => sut.ForUrl((Uri)null!); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Build_AfterForUrl_DelegatesToProxyBuilderWithConfiguredUrl() + { + // Arrange + var proxyBuilder = A.Fake>(); + var fakeApi = A.Fake(); + var url = new Uri("http://localhost:2001/"); + A.CallTo(() => proxyBuilder.UseEncoding(A._)).Returns(proxyBuilder); + A.CallTo(() => proxyBuilder.ForUrl(url)).Returns(proxyBuilder); + A.CallTo(() => proxyBuilder.Build()).Returns(fakeApi); + var sut = new HomeMaticXmlRpcApiBuilder(proxyBuilder); + + // Act + var api = sut.ForUrl(url).Build(); + + // Assert + api.Should().BeSameAs(fakeApi); + A.CallTo(() => proxyBuilder.ForUrl(url)).MustHaveHappenedOnceExactly(); + A.CallTo(() => proxyBuilder.Build()).MustHaveHappenedOnceExactly(); + } + + [Fact] + public void Build_WithoutForUrl_ThrowsInvalidOperationException() + { + // Arrange + var proxyBuilder = A.Fake>(); + var sut = new HomeMaticXmlRpcApiBuilder(proxyBuilder); + + // Act + Action act = () => sut.Build(); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void ForUrl_Uri_ReturnsSameBuilderInstance() + { + // Arrange + var proxyBuilder = A.Fake>(); + var sut = new HomeMaticXmlRpcApiBuilder(proxyBuilder); + + // Act + var returned = sut.ForUrl(new Uri("http://localhost:2001/")); + + // Assert + returned.Should().BeSameAs(sut); + } + + [Fact] + public void Build_AfterForUrlCalledTwice_UsesLastUrl() + { + // Arrange + var proxyBuilder = A.Fake>(); + var fakeApi = A.Fake(); + var firstUrl = new Uri("http://first.local/"); + var secondUrl = new Uri("http://second.local/"); + A.CallTo(() => proxyBuilder.UseEncoding(A._)).Returns(proxyBuilder); + A.CallTo(() => proxyBuilder.ForUrl(A._)).Returns(proxyBuilder); + A.CallTo(() => proxyBuilder.Build()).Returns(fakeApi); + var sut = new HomeMaticXmlRpcApiBuilder(proxyBuilder); + + // Act + sut.ForUrl(firstUrl).ForUrl(secondUrl).Build(); + + // Assert + A.CallTo(() => proxyBuilder.ForUrl(secondUrl)).MustHaveHappenedOnceExactly(); + A.CallTo(() => proxyBuilder.ForUrl(firstUrl)).MustNotHaveHappened(); + } + + [Fact] + public void ForUrl_XmlRpcApiAddress_DelegatesToUriOverloadWithDerivedUrl() + { + // Arrange + var proxyBuilder = A.Fake>(); + var fakeApi = A.Fake(); + A.CallTo(() => proxyBuilder.UseEncoding(A._)).Returns(proxyBuilder); + A.CallTo(() => proxyBuilder.ForUrl(A._)).Returns(proxyBuilder); + A.CallTo(() => proxyBuilder.Build()).Returns(fakeApi); + var apiAddress = new XmlRpcApiAddress(new Uri("http://192.168.1.100/"), CcuDeviceKind.HomeMatic); + var expectedUrl = apiAddress.ToApiUrl(); + var sut = new HomeMaticXmlRpcApiBuilder(proxyBuilder); + + // Act + sut.ForUrl(apiAddress).Build(); + + // Assert + A.CallTo(() => proxyBuilder.ForUrl(expectedUrl)).MustHaveHappenedOnceExactly(); + } +} diff --git a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Client/HomeMaticXmlRpcApiLinkExtensionsTests.cs b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Client/HomeMaticXmlRpcApiLinkExtensionsTests.cs new file mode 100644 index 0000000..f90500a --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Client/HomeMaticXmlRpcApiLinkExtensionsTests.cs @@ -0,0 +1,253 @@ +using CreativeCoders.HomeMatic.XmlRpc.Client; +using CreativeCoders.HomeMatic.XmlRpc.Links; +using FakeItEasy; +using AwesomeAssertions; + +namespace CreativeCoders.HomeMatic.XmlRpc.Tests.Client; + +public class HomeMaticXmlRpcApiLinkExtensionsTests +{ + [Fact] + public async Task GetLinksAsync_TypedFlags_ForwardsBitmaskToApi() + { + // Arrange + var api = A.Fake(); + A.CallTo(() => api.GetLinksAsync("ABC1234567:1", A._)) + .Returns(Task.FromResult(Enumerable.Empty())); + + // Act + await api.GetLinksAsync("ABC1234567:1", + GetLinksFlags.Group | GetLinksFlags.SenderParamSet); + + // Assert + A.CallTo(() => api.GetLinksAsync("ABC1234567:1", 3)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task GetLinksAsync_DefaultFlags_PassesZero() + { + // Arrange + var api = A.Fake(); + A.CallTo(() => api.GetLinksAsync(A._, A._)) + .Returns(Task.FromResult(Enumerable.Empty())); + + // Act + await api.GetLinksAsync("ABC1234567"); + + // Assert + A.CallTo(() => api.GetLinksAsync("ABC1234567", 0)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task GetLinksAsync_EmptyAddress_ForwardsEmptyAddressToApi() + { + // Arrange + var api = A.Fake(); + A.CallTo(() => api.GetLinksAsync(A._, A._)) + .Returns(Task.FromResult(Enumerable.Empty())); + + // Act + await api.GetLinksAsync(string.Empty, GetLinksFlags.Group); + + // Assert + A.CallTo(() => api.GetLinksAsync(string.Empty, 1)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public void GetLinksAsync_NullApi_ThrowsArgumentNullException() + { + // Arrange + IHomeMaticXmlRpcApi api = null!; + + // Act + Action act = () => api.GetLinksAsync("ABC1234567"); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void GetLinksAsync_NullAddress_ThrowsArgumentNullException() + { + // Arrange + var api = A.Fake(); + + // Act + Action act = () => api.GetLinksAsync(null!); + + // Assert + act.Should().Throw(); + } + + [Fact] + public async Task GetLinksAsync_UnderlyingApiThrows_PropagatesException() + { + // Arrange + var api = A.Fake(); + var failure = new InvalidOperationException("boom"); + A.CallTo(() => api.GetLinksAsync(A._, A._)).ThrowsAsync(failure); + + // Act + Func act = () => api.GetLinksAsync("ABC1234567"); + + // Assert + (await act.Should().ThrowAsync()) + .Which.Should().BeSameAs(failure); + } + + [Fact] + public async Task GetLinkInfoAsync_EmptyAddresses_ForwardsToUnderlyingApi() + { + // Arrange + var api = A.Fake(); + A.CallTo(() => api.GetLinkInfoRawAsync(string.Empty, string.Empty)) + .Returns(Task.FromResult>(["N", "D"])); + + // Act + var info = await api.GetLinkInfoAsync(string.Empty, string.Empty); + + // Assert + info.Name.Should().Be("N"); + info.Description.Should().Be("D"); + A.CallTo(() => api.GetLinkInfoRawAsync(string.Empty, string.Empty)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task GetLinkInfoAsync_TwoElementResponse_MapsToNameAndDescription() + { + // Arrange + var api = A.Fake(); + A.CallTo(() => api.GetLinkInfoRawAsync("S", "R")) + .Returns(Task.FromResult>(["MyLink", "Some description"])); + + // Act + var info = await api.GetLinkInfoAsync("S", "R"); + + // Assert + info.Name.Should().Be("MyLink"); + info.Description.Should().Be("Some description"); + } + + [Fact] + public async Task GetLinkInfoAsync_EmptyResponse_ReturnsEmptyStrings() + { + // Arrange + var api = A.Fake(); + A.CallTo(() => api.GetLinkInfoRawAsync("S", "R")) + .Returns(Task.FromResult>([])); + + // Act + var info = await api.GetLinkInfoAsync("S", "R"); + + // Assert + info.Name.Should().BeEmpty(); + info.Description.Should().BeEmpty(); + } + + [Fact] + public async Task GetLinkInfoAsync_SingleElementResponse_DescriptionEmpty() + { + // Arrange + var api = A.Fake(); + A.CallTo(() => api.GetLinkInfoRawAsync("S", "R")) + .Returns(Task.FromResult>(["JustName"])); + + // Act + var info = await api.GetLinkInfoAsync("S", "R"); + + // Assert + info.Name.Should().Be("JustName"); + info.Description.Should().BeEmpty(); + } + + [Fact] + public async Task GetLinkInfoAsync_NullApi_ThrowsArgumentNullException() + { + // Arrange + IHomeMaticXmlRpcApi api = null!; + + // Act + Func act = () => api.GetLinkInfoAsync("S", "R"); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetLinkInfoAsync_NullSenderAddress_ThrowsArgumentNullException() + { + // Arrange + var api = A.Fake(); + + // Act + Func act = () => api.GetLinkInfoAsync(null!, "R"); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetLinkInfoAsync_NullReceiverAddress_ThrowsArgumentNullException() + { + // Arrange + var api = A.Fake(); + + // Act + Func act = () => api.GetLinkInfoAsync("S", null!); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetLinkInfoAsync_RawAsyncReturnsNull_ReturnsEmptyStrings() + { + // Arrange + var api = A.Fake(); + A.CallTo(() => api.GetLinkInfoRawAsync("S", "R")) + .Returns(Task.FromResult>(null!)); + + // Act + var info = await api.GetLinkInfoAsync("S", "R"); + + // Assert + info.Name.Should().BeEmpty(); + info.Description.Should().BeEmpty(); + } + + [Fact] + public async Task GetLinkInfoAsync_RawEntriesAreNull_NormaliseToEmptyStrings() + { + // Arrange + var api = A.Fake(); + A.CallTo(() => api.GetLinkInfoRawAsync("S", "R")) + .Returns(Task.FromResult>([null!, null!])); + + // Act + var info = await api.GetLinkInfoAsync("S", "R"); + + // Assert + info.Name.Should().BeEmpty(); + info.Description.Should().BeEmpty(); + } + + [Fact] + public async Task GetLinkInfoAsync_MoreThanTwoElements_IgnoresExtraEntries() + { + // Arrange + var api = A.Fake(); + A.CallTo(() => api.GetLinkInfoRawAsync("S", "R")) + .Returns(Task.FromResult>(["Name", "Description", "Extra1", "Extra2"])); + + // Act + var info = await api.GetLinkInfoAsync("S", "R"); + + // Assert + info.Name.Should().Be("Name"); + info.Description.Should().Be("Description"); + } +} diff --git a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Converters/FlagsMemberValueConverterLinkFlagsTests.cs b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Converters/FlagsMemberValueConverterLinkFlagsTests.cs new file mode 100644 index 0000000..f782f52 --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Converters/FlagsMemberValueConverterLinkFlagsTests.cs @@ -0,0 +1,66 @@ +using CreativeCoders.HomeMatic.XmlRpc.Converters; +using CreativeCoders.HomeMatic.XmlRpc.Links; +using CreativeCoders.Net.XmlRpc.Model.Values; +using AwesomeAssertions; + +namespace CreativeCoders.HomeMatic.XmlRpc.Tests.Converters; + +public class FlagsMemberValueConverterLinkFlagsTests +{ + [Theory] + [InlineData(0, LinkFlags.None)] + [InlineData(1, LinkFlags.SenderBroken)] + [InlineData(2, LinkFlags.ReceiverBroken)] + [InlineData(3, LinkFlags.SenderBroken | LinkFlags.ReceiverBroken)] + public void ConvertFromValue_IntegerValue_ReturnsLinkFlags(int raw, LinkFlags expected) + { + // Arrange + var sut = new FlagsMemberValueConverter(); + + // Act + var result = sut.ConvertFromValue(new IntegerValue(raw)); + + // Assert + result.Should().Be(expected); + } + + [Fact] + public void ConvertFromValue_NonIntegerValue_ReturnsRawData() + { + // Arrange + var sut = new FlagsMemberValueConverter(); + var stringValue = new StringValue("hello"); + + // Act + var result = sut.ConvertFromValue(stringValue); + + // Assert + result.Should().Be(stringValue.Data); + } + + [Fact] + public void ConvertFromValue_NullValue_ThrowsNullReferenceException() + { + // Arrange + var sut = new FlagsMemberValueConverter(); + + // Act + Action act = () => sut.ConvertFromValue(null!); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void ConvertFromObject_AnyValue_ThrowsNotImplementedException() + { + // Arrange + var sut = new FlagsMemberValueConverter(); + + // Act + Action act = () => sut.ConvertFromObject(LinkFlags.SenderBroken); + + // Assert + act.Should().Throw(); + } +} diff --git a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Converters/FlagsMemberValueConverterRxModesTests.cs b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Converters/FlagsMemberValueConverterRxModesTests.cs new file mode 100644 index 0000000..2ff549c --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Converters/FlagsMemberValueConverterRxModesTests.cs @@ -0,0 +1,27 @@ +using CreativeCoders.HomeMatic.XmlRpc.Converters; +using CreativeCoders.HomeMatic.XmlRpc.Parameters; +using CreativeCoders.Net.XmlRpc.Model.Values; +using AwesomeAssertions; + +namespace CreativeCoders.HomeMatic.XmlRpc.Tests.Converters; + +public class FlagsMemberValueConverterRxModesTests +{ + [Theory] + [InlineData(0, RxModes.None)] + [InlineData(1, RxModes.Always)] + [InlineData(2, RxModes.Burst)] + [InlineData(10, RxModes.Burst | RxModes.WakeUp)] + [InlineData(31, RxModes.Always | RxModes.Burst | RxModes.Config | RxModes.WakeUp | RxModes.LazyConfig)] + public void ConvertFromValue_IntegerValue_ReturnsRxModes(int raw, RxModes expected) + { + // Arrange + var sut = new FlagsMemberValueConverter(); + + // Act + var result = sut.ConvertFromValue(new IntegerValue(raw)); + + // Assert + result.Should().Be(expected); + } +} diff --git a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Converters/ParamSetDictionaryValueConverterTests.cs b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Converters/ParamSetDictionaryValueConverterTests.cs new file mode 100644 index 0000000..5ecee36 --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/Converters/ParamSetDictionaryValueConverterTests.cs @@ -0,0 +1,82 @@ +using System.Collections.Generic; +using CreativeCoders.HomeMatic.XmlRpc.Converters; +using CreativeCoders.Net.XmlRpc.Model; +using CreativeCoders.Net.XmlRpc.Model.Values; +using AwesomeAssertions; + +namespace CreativeCoders.HomeMatic.XmlRpc.Tests.Converters; + +public class ParamSetDictionaryValueConverterTests +{ + [Fact] + public void ConvertFromValue_StringValue_ReturnsEmptyDictionary() + { + var sut = new ParamSetDictionaryValueConverter(); + + var result = sut.ConvertFromValue(new StringValue(string.Empty)); + + result.Should().BeOfType>().Which.Should().BeEmpty(); + } + + [Fact] + public void ConvertFromValue_NonEmptyStringValue_ReturnsEmptyDictionary() + { + var sut = new ParamSetDictionaryValueConverter(); + + var result = sut.ConvertFromValue(new StringValue("unexpected")); + + result.Should().BeOfType>().Which.Should().BeEmpty(); + } + + [Fact] + public void ConvertFromValue_IntegerValue_ReturnsEmptyDictionary() + { + var sut = new ParamSetDictionaryValueConverter(); + + var result = sut.ConvertFromValue(new IntegerValue(0)); + + result.Should().BeOfType>().Which.Should().BeEmpty(); + } + + [Fact] + public void ConvertFromValue_EmptyStruct_ReturnsEmptyDictionary() + { + var sut = new ParamSetDictionaryValueConverter(); + + var result = sut.ConvertFromValue(new StructValue(new Dictionary())); + + result.Should().BeOfType>().Which.Should().BeEmpty(); + } + + [Fact] + public void ConvertFromValue_PopulatedStruct_ReturnsDictionaryWithMembers() + { + var sut = new ParamSetDictionaryValueConverter(); + var members = new Dictionary + { + ["NAME"] = new StringValue("test"), + ["VALUE"] = new IntegerValue(42), + ["ENABLED"] = new BooleanValue(true) + }; + + var result = sut.ConvertFromValue(new StructValue(members)); + + result.Should().BeOfType>() + .Which.Should().BeEquivalentTo(new Dictionary + { + ["NAME"] = "test", + ["VALUE"] = 42, + ["ENABLED"] = true + }); + } + + [Fact] + public void ConvertFromObject_AnyValue_ThrowsNotImplementedException() + { + var sut = new ParamSetDictionaryValueConverter(); + + var act = () => sut.ConvertFromObject(new Dictionary()); + + act.Should().Throw(); + } +} diff --git a/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/CreativeCoders.HomeMatic.XmlRpc.Tests.csproj b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/CreativeCoders.HomeMatic.XmlRpc.Tests.csproj new file mode 100644 index 0000000..409ed1c --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.XmlRpc.Tests/CreativeCoders.HomeMatic.XmlRpc.Tests.csproj @@ -0,0 +1,35 @@ + + + + enable + enable + + false + true + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + +