aiohomematic 2026.1.29__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. aiohomematic/__init__.py +110 -0
  2. aiohomematic/_log_context_protocol.py +29 -0
  3. aiohomematic/api.py +410 -0
  4. aiohomematic/async_support.py +250 -0
  5. aiohomematic/backend_detection.py +462 -0
  6. aiohomematic/central/__init__.py +103 -0
  7. aiohomematic/central/async_rpc_server.py +760 -0
  8. aiohomematic/central/central_unit.py +1152 -0
  9. aiohomematic/central/config.py +463 -0
  10. aiohomematic/central/config_builder.py +772 -0
  11. aiohomematic/central/connection_state.py +160 -0
  12. aiohomematic/central/coordinators/__init__.py +38 -0
  13. aiohomematic/central/coordinators/cache.py +414 -0
  14. aiohomematic/central/coordinators/client.py +480 -0
  15. aiohomematic/central/coordinators/connection_recovery.py +1141 -0
  16. aiohomematic/central/coordinators/device.py +1166 -0
  17. aiohomematic/central/coordinators/event.py +514 -0
  18. aiohomematic/central/coordinators/hub.py +532 -0
  19. aiohomematic/central/decorators.py +184 -0
  20. aiohomematic/central/device_registry.py +229 -0
  21. aiohomematic/central/events/__init__.py +104 -0
  22. aiohomematic/central/events/bus.py +1392 -0
  23. aiohomematic/central/events/integration.py +424 -0
  24. aiohomematic/central/events/types.py +194 -0
  25. aiohomematic/central/health.py +762 -0
  26. aiohomematic/central/rpc_server.py +353 -0
  27. aiohomematic/central/scheduler.py +794 -0
  28. aiohomematic/central/state_machine.py +391 -0
  29. aiohomematic/client/__init__.py +203 -0
  30. aiohomematic/client/_rpc_errors.py +187 -0
  31. aiohomematic/client/backends/__init__.py +48 -0
  32. aiohomematic/client/backends/base.py +335 -0
  33. aiohomematic/client/backends/capabilities.py +138 -0
  34. aiohomematic/client/backends/ccu.py +487 -0
  35. aiohomematic/client/backends/factory.py +116 -0
  36. aiohomematic/client/backends/homegear.py +294 -0
  37. aiohomematic/client/backends/json_ccu.py +252 -0
  38. aiohomematic/client/backends/protocol.py +316 -0
  39. aiohomematic/client/ccu.py +1857 -0
  40. aiohomematic/client/circuit_breaker.py +459 -0
  41. aiohomematic/client/config.py +64 -0
  42. aiohomematic/client/handlers/__init__.py +40 -0
  43. aiohomematic/client/handlers/backup.py +157 -0
  44. aiohomematic/client/handlers/base.py +79 -0
  45. aiohomematic/client/handlers/device_ops.py +1085 -0
  46. aiohomematic/client/handlers/firmware.py +144 -0
  47. aiohomematic/client/handlers/link_mgmt.py +199 -0
  48. aiohomematic/client/handlers/metadata.py +436 -0
  49. aiohomematic/client/handlers/programs.py +144 -0
  50. aiohomematic/client/handlers/sysvars.py +100 -0
  51. aiohomematic/client/interface_client.py +1304 -0
  52. aiohomematic/client/json_rpc.py +2068 -0
  53. aiohomematic/client/request_coalescer.py +282 -0
  54. aiohomematic/client/rpc_proxy.py +629 -0
  55. aiohomematic/client/state_machine.py +324 -0
  56. aiohomematic/const.py +2207 -0
  57. aiohomematic/context.py +275 -0
  58. aiohomematic/converter.py +270 -0
  59. aiohomematic/decorators.py +390 -0
  60. aiohomematic/exceptions.py +185 -0
  61. aiohomematic/hmcli.py +997 -0
  62. aiohomematic/i18n.py +193 -0
  63. aiohomematic/interfaces/__init__.py +407 -0
  64. aiohomematic/interfaces/central.py +1067 -0
  65. aiohomematic/interfaces/client.py +1096 -0
  66. aiohomematic/interfaces/coordinators.py +63 -0
  67. aiohomematic/interfaces/model.py +1921 -0
  68. aiohomematic/interfaces/operations.py +217 -0
  69. aiohomematic/logging_context.py +134 -0
  70. aiohomematic/metrics/__init__.py +125 -0
  71. aiohomematic/metrics/_protocols.py +140 -0
  72. aiohomematic/metrics/aggregator.py +534 -0
  73. aiohomematic/metrics/dataclasses.py +489 -0
  74. aiohomematic/metrics/emitter.py +292 -0
  75. aiohomematic/metrics/events.py +183 -0
  76. aiohomematic/metrics/keys.py +300 -0
  77. aiohomematic/metrics/observer.py +563 -0
  78. aiohomematic/metrics/stats.py +172 -0
  79. aiohomematic/model/__init__.py +189 -0
  80. aiohomematic/model/availability.py +65 -0
  81. aiohomematic/model/calculated/__init__.py +89 -0
  82. aiohomematic/model/calculated/climate.py +276 -0
  83. aiohomematic/model/calculated/data_point.py +315 -0
  84. aiohomematic/model/calculated/field.py +147 -0
  85. aiohomematic/model/calculated/operating_voltage_level.py +286 -0
  86. aiohomematic/model/calculated/support.py +232 -0
  87. aiohomematic/model/custom/__init__.py +214 -0
  88. aiohomematic/model/custom/capabilities/__init__.py +67 -0
  89. aiohomematic/model/custom/capabilities/climate.py +41 -0
  90. aiohomematic/model/custom/capabilities/light.py +87 -0
  91. aiohomematic/model/custom/capabilities/lock.py +44 -0
  92. aiohomematic/model/custom/capabilities/siren.py +63 -0
  93. aiohomematic/model/custom/climate.py +1130 -0
  94. aiohomematic/model/custom/cover.py +722 -0
  95. aiohomematic/model/custom/data_point.py +360 -0
  96. aiohomematic/model/custom/definition.py +300 -0
  97. aiohomematic/model/custom/field.py +89 -0
  98. aiohomematic/model/custom/light.py +1174 -0
  99. aiohomematic/model/custom/lock.py +322 -0
  100. aiohomematic/model/custom/mixins.py +445 -0
  101. aiohomematic/model/custom/profile.py +945 -0
  102. aiohomematic/model/custom/registry.py +251 -0
  103. aiohomematic/model/custom/siren.py +462 -0
  104. aiohomematic/model/custom/switch.py +195 -0
  105. aiohomematic/model/custom/text_display.py +289 -0
  106. aiohomematic/model/custom/valve.py +78 -0
  107. aiohomematic/model/data_point.py +1416 -0
  108. aiohomematic/model/device.py +1840 -0
  109. aiohomematic/model/event.py +216 -0
  110. aiohomematic/model/generic/__init__.py +327 -0
  111. aiohomematic/model/generic/action.py +40 -0
  112. aiohomematic/model/generic/action_select.py +62 -0
  113. aiohomematic/model/generic/binary_sensor.py +30 -0
  114. aiohomematic/model/generic/button.py +31 -0
  115. aiohomematic/model/generic/data_point.py +177 -0
  116. aiohomematic/model/generic/dummy.py +150 -0
  117. aiohomematic/model/generic/number.py +76 -0
  118. aiohomematic/model/generic/select.py +56 -0
  119. aiohomematic/model/generic/sensor.py +76 -0
  120. aiohomematic/model/generic/switch.py +54 -0
  121. aiohomematic/model/generic/text.py +33 -0
  122. aiohomematic/model/hub/__init__.py +100 -0
  123. aiohomematic/model/hub/binary_sensor.py +24 -0
  124. aiohomematic/model/hub/button.py +28 -0
  125. aiohomematic/model/hub/connectivity.py +190 -0
  126. aiohomematic/model/hub/data_point.py +342 -0
  127. aiohomematic/model/hub/hub.py +864 -0
  128. aiohomematic/model/hub/inbox.py +135 -0
  129. aiohomematic/model/hub/install_mode.py +393 -0
  130. aiohomematic/model/hub/metrics.py +208 -0
  131. aiohomematic/model/hub/number.py +42 -0
  132. aiohomematic/model/hub/select.py +52 -0
  133. aiohomematic/model/hub/sensor.py +37 -0
  134. aiohomematic/model/hub/switch.py +43 -0
  135. aiohomematic/model/hub/text.py +30 -0
  136. aiohomematic/model/hub/update.py +221 -0
  137. aiohomematic/model/support.py +592 -0
  138. aiohomematic/model/update.py +140 -0
  139. aiohomematic/model/week_profile.py +1827 -0
  140. aiohomematic/property_decorators.py +719 -0
  141. aiohomematic/py.typed +0 -0
  142. aiohomematic/rega_scripts/accept_device_in_inbox.fn +51 -0
  143. aiohomematic/rega_scripts/create_backup_start.fn +28 -0
  144. aiohomematic/rega_scripts/create_backup_status.fn +89 -0
  145. aiohomematic/rega_scripts/fetch_all_device_data.fn +97 -0
  146. aiohomematic/rega_scripts/get_backend_info.fn +25 -0
  147. aiohomematic/rega_scripts/get_inbox_devices.fn +61 -0
  148. aiohomematic/rega_scripts/get_program_descriptions.fn +31 -0
  149. aiohomematic/rega_scripts/get_serial.fn +44 -0
  150. aiohomematic/rega_scripts/get_service_messages.fn +83 -0
  151. aiohomematic/rega_scripts/get_system_update_info.fn +39 -0
  152. aiohomematic/rega_scripts/get_system_variable_descriptions.fn +31 -0
  153. aiohomematic/rega_scripts/set_program_state.fn +17 -0
  154. aiohomematic/rega_scripts/set_system_variable.fn +19 -0
  155. aiohomematic/rega_scripts/trigger_firmware_update.fn +67 -0
  156. aiohomematic/schemas.py +256 -0
  157. aiohomematic/store/__init__.py +55 -0
  158. aiohomematic/store/dynamic/__init__.py +43 -0
  159. aiohomematic/store/dynamic/command.py +250 -0
  160. aiohomematic/store/dynamic/data.py +175 -0
  161. aiohomematic/store/dynamic/details.py +187 -0
  162. aiohomematic/store/dynamic/ping_pong.py +416 -0
  163. aiohomematic/store/persistent/__init__.py +71 -0
  164. aiohomematic/store/persistent/base.py +285 -0
  165. aiohomematic/store/persistent/device.py +233 -0
  166. aiohomematic/store/persistent/incident.py +380 -0
  167. aiohomematic/store/persistent/paramset.py +241 -0
  168. aiohomematic/store/persistent/session.py +556 -0
  169. aiohomematic/store/serialization.py +150 -0
  170. aiohomematic/store/storage.py +689 -0
  171. aiohomematic/store/types.py +526 -0
  172. aiohomematic/store/visibility/__init__.py +40 -0
  173. aiohomematic/store/visibility/parser.py +141 -0
  174. aiohomematic/store/visibility/registry.py +722 -0
  175. aiohomematic/store/visibility/rules.py +307 -0
  176. aiohomematic/strings.json +237 -0
  177. aiohomematic/support.py +706 -0
  178. aiohomematic/tracing.py +236 -0
  179. aiohomematic/translations/de.json +237 -0
  180. aiohomematic/translations/en.json +237 -0
  181. aiohomematic/type_aliases.py +51 -0
  182. aiohomematic/validator.py +128 -0
  183. aiohomematic-2026.1.29.dist-info/METADATA +296 -0
  184. aiohomematic-2026.1.29.dist-info/RECORD +188 -0
  185. aiohomematic-2026.1.29.dist-info/WHEEL +5 -0
  186. aiohomematic-2026.1.29.dist-info/entry_points.txt +2 -0
  187. aiohomematic-2026.1.29.dist-info/licenses/LICENSE +21 -0
  188. aiohomematic-2026.1.29.dist-info/top_level.txt +1 -0
aiohomematic/hmcli.py ADDED
@@ -0,0 +1,997 @@
1
+ #!/usr/bin/python3
2
+ # SPDX-License-Identifier: MIT
3
+ # Copyright (c) 2021-2026
4
+ """
5
+ Commandline tool to query Homematic hubs via XML-RPC.
6
+
7
+ Public API of this module is defined by __all__.
8
+
9
+ This module provides a command-line interface with:
10
+ - Device discovery commands (list-devices, list-channels, list-parameters)
11
+ - Parameter read/write operations (get, set)
12
+ - Interactive mode with command history and completion
13
+ - Shell completion script generation
14
+
15
+ Usage examples::
16
+
17
+ # List all devices
18
+ hmcli -H 192.168.1.100 -p 2010 list-devices
19
+
20
+ # List channels of a device
21
+ hmcli -H 192.168.1.100 -p 2010 list-channels VCU0000001
22
+
23
+ # Get a parameter value
24
+ hmcli -H 192.168.1.100 -p 2010 get -a VCU0000001:1 -n STATE
25
+
26
+ # Set a parameter value
27
+ hmcli -H 192.168.1.100 -p 2010 set -a VCU0000001:1 -n STATE -v 1 --type bool
28
+
29
+ # Interactive mode
30
+ hmcli -H 192.168.1.100 -p 2010 interactive
31
+
32
+ # Generate shell completion
33
+ hmcli --generate-completion bash > /etc/bash_completion.d/hmcli
34
+
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import argparse
40
+ import cmd
41
+ import contextlib
42
+ import json
43
+ import readline
44
+ from ssl import SSLContext
45
+ import sys
46
+ from typing import Any, Final
47
+ from xmlrpc.client import ServerProxy
48
+
49
+ from aiohomematic import __version__
50
+ from aiohomematic.const import ParamsetKey
51
+ from aiohomematic.support import build_xml_rpc_headers, build_xml_rpc_uri, get_tls_context
52
+
53
+ # Define public API for this module (CLI only)
54
+ __all__ = ["main"]
55
+
56
+ # Shell completion templates
57
+ _BASH_COMPLETION: Final = """# Bash completion for hmcli
58
+ _hmcli_completion() {
59
+ local cur prev commands
60
+ COMPREPLY=()
61
+ cur="${COMP_WORDS[COMP_CWORD]}"
62
+ prev="${COMP_WORDS[COMP_CWORD-1]}"
63
+ commands="list-devices list-channels list-parameters device-info get set interactive"
64
+
65
+ case "${prev}" in
66
+ hmcli)
67
+ COMPREPLY=( $(compgen -W "--host -H --port -p --username -U --password -P --tls --verify --json --help --version --generate-completion ${commands}" -- ${cur}) )
68
+ return 0
69
+ ;;
70
+ -H|--host)
71
+ return 0
72
+ ;;
73
+ -p|--port)
74
+ COMPREPLY=( $(compgen -W "2000 2001 2002 2010 42000 42001 42002 42010" -- ${cur}) )
75
+ return 0
76
+ ;;
77
+ list-devices|interactive)
78
+ return 0
79
+ ;;
80
+ list-channels|device-info)
81
+ return 0
82
+ ;;
83
+ list-parameters)
84
+ return 0
85
+ ;;
86
+ get|set)
87
+ COMPREPLY=( $(compgen -W "-a --address -n --parameter --paramset_key" -- ${cur}) )
88
+ return 0
89
+ ;;
90
+ -a|--address)
91
+ return 0
92
+ ;;
93
+ -n|--parameter)
94
+ return 0
95
+ ;;
96
+ --type)
97
+ COMPREPLY=( $(compgen -W "int float bool str" -- ${cur}) )
98
+ return 0
99
+ ;;
100
+ --paramset_key)
101
+ COMPREPLY=( $(compgen -W "VALUES MASTER" -- ${cur}) )
102
+ return 0
103
+ ;;
104
+ --generate-completion)
105
+ COMPREPLY=( $(compgen -W "bash zsh fish" -- ${cur}) )
106
+ return 0
107
+ ;;
108
+ *)
109
+ COMPREPLY=( $(compgen -W "${commands}" -- ${cur}) )
110
+ return 0
111
+ ;;
112
+ esac
113
+ }
114
+ complete -F _hmcli_completion hmcli
115
+ """
116
+
117
+ _ZSH_COMPLETION: Final = """#compdef hmcli
118
+ # Zsh completion for hmcli
119
+
120
+ _hmcli() {
121
+ local -a commands
122
+ commands=(
123
+ 'list-devices:List all devices'
124
+ 'list-channels:List channels of a device'
125
+ 'list-parameters:List parameters of a channel'
126
+ 'device-info:Show detailed device information'
127
+ 'get:Get parameter value'
128
+ 'set:Set parameter value'
129
+ 'interactive:Start interactive mode'
130
+ )
131
+
132
+ _arguments -C \\
133
+ '(-H --host)'{-H,--host}'[Hostname/IP address]:host:' \\
134
+ '(-p --port)'{-p,--port}'[Port number]:port:(2000 2001 2002 2010 42000 42001 42002 42010)' \\
135
+ '(-U --username)'{-U,--username}'[Username]:username:' \\
136
+ '(-P --password)'{-P,--password}'[Password]:password:' \\
137
+ '(-t --tls)'{-t,--tls}'[Enable TLS]' \\
138
+ '(-v --verify)'{-v,--verify}'[Verify TLS certificate]' \\
139
+ '(-j --json)'{-j,--json}'[Output as JSON]' \\
140
+ '--version[Show version]' \\
141
+ '--help[Show help]' \\
142
+ '--generate-completion[Generate shell completion]:shell:(bash zsh fish)' \\
143
+ '1:command:->commands' \\
144
+ '*::arg:->args'
145
+
146
+ case $state in
147
+ commands)
148
+ _describe -t commands 'hmcli commands' commands
149
+ ;;
150
+ args)
151
+ case $words[1] in
152
+ get|set)
153
+ _arguments \\
154
+ '(-a --address)'{-a,--address}'[Device address]:address:' \\
155
+ '(-n --parameter)'{-n,--parameter}'[Parameter name]:parameter:' \\
156
+ '--paramset_key[Paramset key]:key:(VALUES MASTER)' \\
157
+ '(-v --value)'{-v,--value}'[Value to set]:value:' \\
158
+ '--type[Value type]:type:(int float bool str)'
159
+ ;;
160
+ list-channels|device-info)
161
+ _arguments '1:address:'
162
+ ;;
163
+ list-parameters)
164
+ _arguments '1:channel_address:'
165
+ ;;
166
+ esac
167
+ ;;
168
+ esac
169
+ }
170
+
171
+ _hmcli "$@"
172
+ """
173
+
174
+ _FISH_COMPLETION: Final = """# Fish completion for hmcli
175
+
176
+ # Disable file completion by default
177
+ complete -c hmcli -f
178
+
179
+ # Global options
180
+ complete -c hmcli -s H -l host -d 'Hostname/IP address' -x
181
+ complete -c hmcli -s p -l port -d 'Port number' -xa '2000 2001 2002 2010 42000 42001 42002 42010'
182
+ complete -c hmcli -s U -l username -d 'Username' -x
183
+ complete -c hmcli -s P -l password -d 'Password' -x
184
+ complete -c hmcli -s t -l tls -d 'Enable TLS'
185
+ complete -c hmcli -s v -l verify -d 'Verify TLS certificate'
186
+ complete -c hmcli -s j -l json -d 'Output as JSON'
187
+ complete -c hmcli -l version -d 'Show version'
188
+ complete -c hmcli -l help -d 'Show help'
189
+ complete -c hmcli -l generate-completion -d 'Generate shell completion' -xa 'bash zsh fish'
190
+
191
+ # Commands
192
+ complete -c hmcli -n '__fish_use_subcommand' -a list-devices -d 'List all devices'
193
+ complete -c hmcli -n '__fish_use_subcommand' -a list-channels -d 'List channels of a device'
194
+ complete -c hmcli -n '__fish_use_subcommand' -a list-parameters -d 'List parameters of a channel'
195
+ complete -c hmcli -n '__fish_use_subcommand' -a device-info -d 'Show device information'
196
+ complete -c hmcli -n '__fish_use_subcommand' -a get -d 'Get parameter value'
197
+ complete -c hmcli -n '__fish_use_subcommand' -a set -d 'Set parameter value'
198
+ complete -c hmcli -n '__fish_use_subcommand' -a interactive -d 'Start interactive mode'
199
+
200
+ # get/set options
201
+ complete -c hmcli -n '__fish_seen_subcommand_from get set' -s a -l address -d 'Device address' -x
202
+ complete -c hmcli -n '__fish_seen_subcommand_from get set' -s n -l parameter -d 'Parameter name' -x
203
+ complete -c hmcli -n '__fish_seen_subcommand_from get set' -l paramset_key -d 'Paramset key' -xa 'VALUES MASTER'
204
+ complete -c hmcli -n '__fish_seen_subcommand_from set' -s v -l value -d 'Value to set' -x
205
+ complete -c hmcli -n '__fish_seen_subcommand_from set' -l type -d 'Value type' -xa 'int float bool str'
206
+ """
207
+
208
+
209
+ class _HmCliConnection:
210
+ """Manage connection to Homematic hub."""
211
+
212
+ def __init__(
213
+ self,
214
+ *,
215
+ host: str,
216
+ port: int,
217
+ path: str | None = None,
218
+ username: str | None = None,
219
+ password: str | None = None,
220
+ tls: bool = False,
221
+ verify_tls: bool = False,
222
+ ) -> None:
223
+ """Initialize connection."""
224
+ self.host = host
225
+ self.port = port
226
+ self.path = path
227
+ self.username = username
228
+ self.password = password
229
+ self.tls = tls
230
+ self.verify_tls = verify_tls
231
+ self._proxy: ServerProxy | None = None
232
+ self._devices: list[dict[str, Any]] | None = None
233
+
234
+ @property
235
+ def proxy(self) -> ServerProxy:
236
+ """Return XML-RPC proxy, creating if needed."""
237
+ if self._proxy is None:
238
+ url = build_xml_rpc_uri(
239
+ host=self.host,
240
+ port=self.port,
241
+ path=self.path,
242
+ tls=self.tls,
243
+ )
244
+ headers = build_xml_rpc_headers(
245
+ username=self.username or "",
246
+ password=self.password or "",
247
+ )
248
+ context: SSLContext | None = None
249
+ if self.tls:
250
+ context = get_tls_context(verify_tls=self.verify_tls)
251
+ self._proxy = ServerProxy(url, context=context, headers=headers)
252
+ return self._proxy
253
+
254
+ def get_channel_addresses(self, *, device_address: str | None = None) -> list[str]:
255
+ """Return list of channel addresses."""
256
+ devices = self.list_devices()
257
+ channels = [d["ADDRESS"] for d in devices if ":" in d["ADDRESS"]]
258
+ if device_address:
259
+ channels = [c for c in channels if c.startswith(f"{device_address}:")]
260
+ return sorted(channels)
261
+
262
+ def get_device_addresses(self) -> list[str]:
263
+ """Return list of device addresses (without channels)."""
264
+ devices = self.list_devices()
265
+ return sorted({d["ADDRESS"].split(":")[0] for d in devices if ":" not in d["ADDRESS"]})
266
+
267
+ def get_device_info(self, *, address: str) -> dict[str, Any] | None:
268
+ """Return device info for an address."""
269
+ devices = self.list_devices()
270
+ for device in devices:
271
+ if device["ADDRESS"] == address:
272
+ return device
273
+ return None
274
+
275
+ def get_paramset(self, *, address: str, paramset_key: str) -> dict[str, Any]:
276
+ """Get full paramset."""
277
+ return self.proxy.getParamset(address, paramset_key) # type: ignore[return-value]
278
+
279
+ def get_paramset_description(self, *, address: str, paramset_key: str = "VALUES") -> dict[str, Any]:
280
+ """Return paramset description for a channel."""
281
+ return self.proxy.getParamsetDescription(address, paramset_key) # type: ignore[return-value]
282
+
283
+ def get_value(self, *, address: str, parameter: str) -> Any:
284
+ """Get parameter value."""
285
+ return self.proxy.getValue(address, parameter)
286
+
287
+ def list_devices(self) -> list[dict[str, Any]]:
288
+ """List all devices from the hub."""
289
+ if self._devices is None:
290
+ self._devices = self.proxy.listDevices() # type: ignore[assignment]
291
+ return self._devices or []
292
+
293
+ def put_paramset(self, *, address: str, paramset_key: str, values: dict[str, Any]) -> None:
294
+ """Put paramset values."""
295
+ self.proxy.putParamset(address, paramset_key, values)
296
+
297
+ def set_value(self, *, address: str, parameter: str, value: Any) -> None:
298
+ """Set parameter value."""
299
+ self.proxy.setValue(address, parameter, value)
300
+
301
+
302
+ class _InteractiveShell(cmd.Cmd):
303
+ """Interactive shell for hmcli."""
304
+
305
+ intro = "Homematic CLI - Interactive Mode. Type 'help' for commands, 'quit' to exit."
306
+ prompt = "hmcli> "
307
+
308
+ def __init__(self, *, connection: _HmCliConnection, json_output: bool = False) -> None:
309
+ """Initialize interactive shell."""
310
+ super().__init__()
311
+ self.connection = connection
312
+ self.json_output = json_output
313
+ self._setup_readline()
314
+
315
+ def complete_device_info( # kwonly: disable
316
+ self, text: str, line: str, begidx: int, endidx: int
317
+ ) -> list[str]:
318
+ """Complete addresses for device-info."""
319
+ try:
320
+ devices = self.connection.list_devices()
321
+ addresses = [d["ADDRESS"] for d in devices]
322
+ return [a for a in addresses if a.startswith(text)]
323
+ except Exception:
324
+ return []
325
+
326
+ def complete_get( # kwonly: disable
327
+ self, text: str, line: str, begidx: int, endidx: int
328
+ ) -> list[str]:
329
+ """Complete for get command."""
330
+ parts = line.split()
331
+ if len(parts) <= 2:
332
+ # Complete channel address
333
+ try:
334
+ channels = self.connection.get_channel_addresses()
335
+ return [c for c in channels if c.startswith(text)]
336
+ except Exception:
337
+ return []
338
+ elif len(parts) == 3:
339
+ # Complete parameter name
340
+ try:
341
+ address = parts[1]
342
+ params = self.connection.get_paramset_description(address=address)
343
+ return [p for p in params if p.startswith(text.upper())]
344
+ except Exception:
345
+ return []
346
+ elif len(parts) == 4:
347
+ return [k for k in ("VALUES", "MASTER") if k.startswith(text.upper())]
348
+ return []
349
+
350
+ def complete_list_channels( # kwonly: disable
351
+ self, text: str, line: str, begidx: int, endidx: int
352
+ ) -> list[str]:
353
+ """Complete device addresses for list-channels."""
354
+ try:
355
+ addresses = self.connection.get_device_addresses()
356
+ return [a for a in addresses if a.startswith(text)]
357
+ except Exception:
358
+ return []
359
+
360
+ def complete_list_parameters( # kwonly: disable
361
+ self, text: str, line: str, begidx: int, endidx: int
362
+ ) -> list[str]:
363
+ """Complete channel addresses for list-parameters."""
364
+ parts = line.split()
365
+ if len(parts) <= 2:
366
+ try:
367
+ channels = self.connection.get_channel_addresses()
368
+ return [c for c in channels if c.startswith(text)]
369
+ except Exception:
370
+ return []
371
+ elif len(parts) == 3:
372
+ return [k for k in ("VALUES", "MASTER") if k.startswith(text.upper())]
373
+ return []
374
+
375
+ def complete_set( # kwonly: disable
376
+ self, text: str, line: str, begidx: int, endidx: int
377
+ ) -> list[str]:
378
+ """Complete for set command."""
379
+ parts = line.split()
380
+ if len(parts) <= 2:
381
+ try:
382
+ channels = self.connection.get_channel_addresses()
383
+ return [c for c in channels if c.startswith(text)]
384
+ except Exception:
385
+ return []
386
+ elif len(parts) == 3:
387
+ try:
388
+ address = parts[1]
389
+ params = self.connection.get_paramset_description(address=address)
390
+ return [p for p in params if p.startswith(text.upper())]
391
+ except Exception:
392
+ return []
393
+ return []
394
+
395
+ def default(self, line: str) -> None: # kwonly: disable
396
+ """Handle unknown commands."""
397
+ print(f"Unknown command: {line}. Type 'help' for available commands.")
398
+
399
+ def do_EOF(self, arg: str) -> bool: # kwonly: disable
400
+ """Handle Ctrl+D."""
401
+ print()
402
+ return self.do_quit(arg)
403
+
404
+ def do_device_info(self, arg: str) -> None: # kwonly: disable
405
+ """Show detailed device information (usage: device-info <address>)."""
406
+ if not arg:
407
+ print("Usage: device-info <address>", file=sys.stderr)
408
+ return
409
+
410
+ address = arg.strip()
411
+ try:
412
+ if (info := self.connection.get_device_info(address=address)) is None:
413
+ print(f"Device not found: {address}")
414
+ return
415
+
416
+ if self.json_output:
417
+ print(json.dumps(info, ensure_ascii=False, indent=2))
418
+ else:
419
+ for key, value in sorted(info.items()):
420
+ print(f"{key}: {value}")
421
+ except Exception as ex:
422
+ print(f"Error: {ex}", file=sys.stderr)
423
+
424
+ def do_exit(self, arg: str) -> bool: # kwonly: disable
425
+ """Exit the interactive shell."""
426
+ return self.do_quit(arg)
427
+
428
+ def do_get(self, arg: str) -> None: # kwonly: disable
429
+ """Get parameter value (usage: get <channel_address> <parameter> [VALUES|MASTER])."""
430
+ parts = arg.strip().split()
431
+ if len(parts) < 2:
432
+ print("Usage: get <channel_address> <parameter> [VALUES|MASTER]", file=sys.stderr)
433
+ return
434
+
435
+ address = parts[0]
436
+ parameter = parts[1]
437
+ paramset_key = parts[2] if len(parts) > 2 else "VALUES"
438
+
439
+ try:
440
+ if paramset_key == "VALUES":
441
+ result = self.connection.get_value(address=address, parameter=parameter)
442
+ else:
443
+ paramset = self.connection.get_paramset(address=address, paramset_key=paramset_key)
444
+ result = paramset.get(parameter, "Parameter not found")
445
+
446
+ self._print_result(result=result, context={"address": address, "parameter": parameter})
447
+ except Exception as ex:
448
+ print(f"Error: {ex}", file=sys.stderr)
449
+
450
+ def do_json(self, arg: str) -> None: # kwonly: disable
451
+ """Toggle JSON output mode (usage: json [on|off])."""
452
+ if arg.strip().lower() in ("on", "true", "1"):
453
+ self.json_output = True
454
+ print("JSON output enabled")
455
+ elif arg.strip().lower() in ("off", "false", "0"):
456
+ self.json_output = False
457
+ print("JSON output disabled")
458
+ else:
459
+ self.json_output = not self.json_output
460
+ print(f"JSON output {'enabled' if self.json_output else 'disabled'}")
461
+
462
+ def do_list_channels(self, arg: str) -> None: # kwonly: disable
463
+ """List channels of a device (usage: list-channels <device_address>)."""
464
+ if not arg:
465
+ print("Usage: list-channels <device_address>", file=sys.stderr)
466
+ return
467
+
468
+ device_address = arg.strip()
469
+ try:
470
+ devices = self.connection.list_devices()
471
+ if not (channels := [d for d in devices if d["ADDRESS"].startswith(f"{device_address}:")]):
472
+ print(f"No channels found for device {device_address}")
473
+ return
474
+
475
+ headers = ["ADDRESS", "TYPE", "FLAGS", "DIRECTION"]
476
+ rows = [
477
+ [
478
+ d.get("ADDRESS", ""),
479
+ d.get("TYPE", ""),
480
+ str(d.get("FLAGS", "")),
481
+ str(d.get("DIRECTION", "")),
482
+ ]
483
+ for d in sorted(channels, key=lambda x: x.get("ADDRESS", ""))
484
+ ]
485
+ self._print_table(headers=headers, rows=rows)
486
+ except Exception as ex:
487
+ print(f"Error: {ex}", file=sys.stderr)
488
+
489
+ def do_list_devices(self, arg: str) -> None: # kwonly: disable
490
+ """List all devices."""
491
+ del arg # unused
492
+ try:
493
+ devices = self.connection.list_devices()
494
+ # Filter to only parent devices (no channel suffix)
495
+ parent_devices = [d for d in devices if ":" not in d["ADDRESS"]]
496
+
497
+ headers = ["ADDRESS", "TYPE", "FIRMWARE", "FLAGS"]
498
+ rows = [
499
+ [
500
+ d.get("ADDRESS", ""),
501
+ d.get("TYPE", ""),
502
+ d.get("FIRMWARE", ""),
503
+ str(d.get("FLAGS", "")),
504
+ ]
505
+ for d in sorted(parent_devices, key=lambda x: x.get("ADDRESS", ""))
506
+ ]
507
+ self._print_table(headers=headers, rows=rows)
508
+ except Exception as ex:
509
+ print(f"Error: {ex}", file=sys.stderr)
510
+
511
+ def do_list_parameters(self, arg: str) -> None: # kwonly: disable
512
+ """List parameters of a channel (usage: list-parameters <address> [VALUES|MASTER])."""
513
+ if not (parts := arg.strip().split()):
514
+ print("Usage: list-parameters <channel_address> [VALUES|MASTER]", file=sys.stderr)
515
+ return
516
+
517
+ channel_address = parts[0]
518
+ paramset_key = parts[1] if len(parts) > 1 else "VALUES"
519
+
520
+ try:
521
+ params = self.connection.get_paramset_description(address=channel_address, paramset_key=paramset_key)
522
+
523
+ headers = ["PARAMETER", "TYPE", "OPERATIONS", "MIN", "MAX", "DEFAULT"]
524
+ rows = []
525
+ for name, info in sorted(params.items()):
526
+ ops = []
527
+ op_val = info.get("OPERATIONS", 0)
528
+ if op_val & 1:
529
+ ops.append("R")
530
+ if op_val & 2:
531
+ ops.append("W")
532
+ if op_val & 4:
533
+ ops.append("E")
534
+
535
+ rows.append(
536
+ [
537
+ name,
538
+ info.get("TYPE", ""),
539
+ "".join(ops),
540
+ str(info.get("MIN", "")),
541
+ str(info.get("MAX", "")),
542
+ str(info.get("DEFAULT", "")),
543
+ ]
544
+ )
545
+ self._print_table(headers=headers, rows=rows)
546
+ except Exception as ex:
547
+ print(f"Error: {ex}", file=sys.stderr)
548
+
549
+ def do_quit(self, arg: str) -> bool: # kwonly: disable
550
+ """Exit the interactive shell."""
551
+ del arg # unused
552
+ self._save_history()
553
+ print("Goodbye!")
554
+ return True
555
+
556
+ def do_set(self, arg: str) -> None: # kwonly: disable
557
+ """Set parameter value (usage: set <address> <parameter> <value> [type])."""
558
+ parts = arg.strip().split()
559
+ if len(parts) < 3:
560
+ print("Usage: set <channel_address> <parameter> <value> [int|float|bool] [VALUES|MASTER]", file=sys.stderr)
561
+ return
562
+
563
+ address = parts[0]
564
+ parameter = parts[1]
565
+ value_str = parts[2]
566
+ value_type = parts[3] if len(parts) > 3 and parts[3] in ("int", "float", "bool") else None
567
+ paramset_key = parts[-1] if parts[-1] in ("VALUES", "MASTER") else "VALUES"
568
+
569
+ try:
570
+ value: Any = value_str
571
+ if value_type == "int":
572
+ value = int(value_str)
573
+ elif value_type == "float":
574
+ value = float(value_str)
575
+ elif value_type == "bool":
576
+ value = value_str.lower() in ("1", "true", "yes", "on")
577
+
578
+ if paramset_key == "VALUES":
579
+ self.connection.set_value(address=address, parameter=parameter, value=value)
580
+ else:
581
+ self.connection.put_paramset(address=address, paramset_key=paramset_key, values={parameter: value})
582
+
583
+ print(f"Set {address}.{parameter} = {value}")
584
+ except Exception as ex:
585
+ print(f"Error: {ex}", file=sys.stderr)
586
+
587
+ def emptyline(self) -> bool:
588
+ """Do nothing on empty line."""
589
+ return False
590
+
591
+ def _print_result(self, *, result: Any, context: dict[str, Any] | None = None) -> None:
592
+ """Print a result value."""
593
+ if self.json_output:
594
+ output = {"value": result}
595
+ if context:
596
+ output.update(context)
597
+ print(json.dumps(output, ensure_ascii=False))
598
+ else:
599
+ print(result)
600
+
601
+ def _print_table(self, *, headers: list[str], rows: list[list[str]]) -> None:
602
+ """Print data as formatted table."""
603
+ if self.json_output:
604
+ data = [dict(zip(headers, row, strict=False)) for row in rows]
605
+ print(json.dumps(data, ensure_ascii=False, indent=2))
606
+ return
607
+
608
+ if not rows:
609
+ print("No data found.")
610
+ return
611
+
612
+ # Calculate column widths
613
+ widths = [len(h) for h in headers]
614
+ for row in rows:
615
+ for i, cell in enumerate(row):
616
+ widths[i] = max(widths[i], len(str(cell)))
617
+
618
+ # Print header
619
+ header_line = " ".join(h.ljust(widths[i]) for i, h in enumerate(headers))
620
+ print(header_line)
621
+ print("-" * len(header_line))
622
+
623
+ # Print rows
624
+ for row in rows:
625
+ print(" ".join(str(cell).ljust(widths[i]) for i, cell in enumerate(row)))
626
+
627
+ def _save_history(self) -> None:
628
+ """Save command history."""
629
+ with contextlib.suppress(OSError):
630
+ readline.write_history_file(".hmcli_history")
631
+
632
+ def _setup_readline(self) -> None:
633
+ """Configure readline for history and completion."""
634
+ with contextlib.suppress(FileNotFoundError):
635
+ readline.read_history_file(".hmcli_history")
636
+ readline.set_history_length(1000)
637
+ readline.parse_and_bind("tab: complete")
638
+
639
+
640
+ def _format_output(
641
+ *,
642
+ data: Any,
643
+ as_json: bool,
644
+ context: dict[str, Any] | None = None,
645
+ ) -> str:
646
+ """Format output as JSON or plain text."""
647
+ if as_json:
648
+ output = {"value": data}
649
+ if context:
650
+ output.update(context)
651
+ return json.dumps(output, ensure_ascii=False)
652
+ return str(data)
653
+
654
+
655
+ def _print_table(
656
+ *,
657
+ headers: list[str],
658
+ rows: list[list[str]],
659
+ as_json: bool,
660
+ ) -> None:
661
+ """Print data as table or JSON."""
662
+ if as_json:
663
+ data = [dict(zip(headers, row, strict=False)) for row in rows]
664
+ print(json.dumps(data, ensure_ascii=False, indent=2))
665
+ return
666
+
667
+ if not rows:
668
+ print("No data found.")
669
+ return
670
+
671
+ widths = [len(h) for h in headers]
672
+ for row in rows:
673
+ for i, cell in enumerate(row):
674
+ widths[i] = max(widths[i], len(str(cell)))
675
+
676
+ header_line = " ".join(h.ljust(widths[i]) for i, h in enumerate(headers))
677
+ print(header_line)
678
+ print("-" * len(header_line))
679
+ for row in rows:
680
+ print(" ".join(str(cell).ljust(widths[i]) for i, cell in enumerate(row)))
681
+
682
+
683
+ def _cmd_list_devices(
684
+ *,
685
+ connection: _HmCliConnection,
686
+ args: argparse.Namespace,
687
+ ) -> int:
688
+ """Handle list-devices command."""
689
+ try:
690
+ devices = connection.list_devices()
691
+ parent_devices = [d for d in devices if ":" not in d["ADDRESS"]]
692
+
693
+ headers = ["ADDRESS", "TYPE", "FIRMWARE", "FLAGS"]
694
+ rows = [
695
+ [d.get("ADDRESS", ""), d.get("TYPE", ""), d.get("FIRMWARE", ""), str(d.get("FLAGS", ""))]
696
+ for d in sorted(parent_devices, key=lambda x: x.get("ADDRESS", ""))
697
+ ]
698
+ _print_table(headers=headers, rows=rows, as_json=args.json)
699
+ except Exception as ex:
700
+ print(f"Error: {ex}", file=sys.stderr)
701
+ return 1
702
+ return 0
703
+
704
+
705
+ def _cmd_list_channels(
706
+ *,
707
+ connection: _HmCliConnection,
708
+ args: argparse.Namespace,
709
+ ) -> int:
710
+ """Handle list-channels command."""
711
+ try:
712
+ devices = connection.list_devices()
713
+
714
+ if not (channels := [d for d in devices if d["ADDRESS"].startswith(f"{args.device_address}:")]):
715
+ print(f"No channels found for device {args.device_address}")
716
+ return 1
717
+
718
+ headers = ["ADDRESS", "TYPE", "FLAGS", "DIRECTION"]
719
+ rows = [
720
+ [d.get("ADDRESS", ""), d.get("TYPE", ""), str(d.get("FLAGS", "")), str(d.get("DIRECTION", ""))]
721
+ for d in sorted(channels, key=lambda x: x.get("ADDRESS", ""))
722
+ ]
723
+ _print_table(headers=headers, rows=rows, as_json=args.json)
724
+ except Exception as ex:
725
+ print(f"Error: {ex}", file=sys.stderr)
726
+ return 1
727
+ return 0
728
+
729
+
730
+ def _cmd_list_parameters(
731
+ *,
732
+ connection: _HmCliConnection,
733
+ args: argparse.Namespace,
734
+ ) -> int:
735
+ """Handle list-parameters command."""
736
+ try:
737
+ params = connection.get_paramset_description(address=args.channel_address, paramset_key=args.paramset_key)
738
+
739
+ headers = ["PARAMETER", "TYPE", "OPERATIONS", "MIN", "MAX", "DEFAULT"]
740
+ rows = []
741
+ for name, info in sorted(params.items()):
742
+ ops = []
743
+ op_val = info.get("OPERATIONS", 0)
744
+ if op_val & 1:
745
+ ops.append("R")
746
+ if op_val & 2:
747
+ ops.append("W")
748
+ if op_val & 4:
749
+ ops.append("E")
750
+
751
+ rows.append(
752
+ [
753
+ name,
754
+ info.get("TYPE", ""),
755
+ "".join(ops),
756
+ str(info.get("MIN", "")),
757
+ str(info.get("MAX", "")),
758
+ str(info.get("DEFAULT", "")),
759
+ ]
760
+ )
761
+ _print_table(headers=headers, rows=rows, as_json=args.json)
762
+ except Exception as ex:
763
+ print(f"Error: {ex}", file=sys.stderr)
764
+ return 1
765
+ return 0
766
+
767
+
768
+ def _cmd_device_info(
769
+ *,
770
+ connection: _HmCliConnection,
771
+ args: argparse.Namespace,
772
+ ) -> int:
773
+ """Handle device-info command."""
774
+ try:
775
+ if (info := connection.get_device_info(address=args.device_address)) is None:
776
+ print(f"Device not found: {args.device_address}")
777
+ return 1
778
+
779
+ if args.json:
780
+ print(json.dumps(info, ensure_ascii=False, indent=2))
781
+ else:
782
+ for key, value in sorted(info.items()):
783
+ print(f"{key}: {value}")
784
+ except Exception as ex:
785
+ print(f"Error: {ex}", file=sys.stderr)
786
+ return 1
787
+ return 0
788
+
789
+
790
+ def _cmd_get(
791
+ *,
792
+ connection: _HmCliConnection,
793
+ args: argparse.Namespace,
794
+ ) -> int:
795
+ """Handle get command."""
796
+ try:
797
+ if args.paramset_key == ParamsetKey.VALUES:
798
+ result = connection.get_value(address=args.address, parameter=args.parameter)
799
+ else:
800
+ paramset = connection.get_paramset(address=args.address, paramset_key=args.paramset_key)
801
+ if args.parameter not in paramset:
802
+ print(f"Parameter not found: {args.parameter}", file=sys.stderr)
803
+ return 1
804
+ result = paramset[args.parameter]
805
+
806
+ print(
807
+ _format_output(
808
+ data=result, as_json=args.json, context={"address": args.address, "parameter": args.parameter}
809
+ )
810
+ )
811
+ except Exception as ex:
812
+ print(f"Error: {ex}", file=sys.stderr)
813
+ return 1
814
+ return 0
815
+
816
+
817
+ def _cmd_set(
818
+ *,
819
+ connection: _HmCliConnection,
820
+ args: argparse.Namespace,
821
+ ) -> int:
822
+ """Handle set command."""
823
+ try:
824
+ value: Any = args.value
825
+ if args.type == "int":
826
+ value = int(args.value)
827
+ elif args.type == "float":
828
+ value = float(args.value)
829
+ elif args.type == "bool":
830
+ value = bool(int(args.value))
831
+
832
+ if args.paramset_key == ParamsetKey.VALUES:
833
+ connection.set_value(address=args.address, parameter=args.parameter, value=value)
834
+ else:
835
+ connection.put_paramset(
836
+ address=args.address, paramset_key=args.paramset_key, values={args.parameter: value}
837
+ )
838
+ except Exception as ex:
839
+ print(f"Error: {ex}", file=sys.stderr)
840
+ return 1
841
+ return 0
842
+
843
+
844
+ def _cmd_interactive(
845
+ *,
846
+ connection: _HmCliConnection,
847
+ args: argparse.Namespace,
848
+ ) -> int:
849
+ """Handle interactive command."""
850
+ shell = _InteractiveShell(connection=connection, json_output=args.json)
851
+ try:
852
+ shell.cmdloop()
853
+ except KeyboardInterrupt:
854
+ print("\nInterrupted")
855
+ return 0
856
+
857
+
858
+ def main() -> None:
859
+ """Start the CLI."""
860
+ parser = argparse.ArgumentParser(
861
+ description="Commandline tool to query Homematic hubs via XML-RPC",
862
+ formatter_class=argparse.RawDescriptionHelpFormatter,
863
+ epilog="""
864
+ Examples:
865
+ hmcli -H 192.168.1.100 -p 2010 list-devices
866
+ hmcli -H 192.168.1.100 -p 2010 list-channels VCU0000001
867
+ hmcli -H 192.168.1.100 -p 2010 list-parameters VCU0000001:1
868
+ hmcli -H 192.168.1.100 -p 2010 get -a VCU0000001:1 -n STATE
869
+ hmcli -H 192.168.1.100 -p 2010 set -a VCU0000001:1 -n STATE -v 1 --type bool
870
+ hmcli -H 192.168.1.100 -p 2010 interactive
871
+ hmcli --generate-completion bash > /etc/bash_completion.d/hmcli
872
+ """,
873
+ )
874
+ parser.add_argument("--version", action="version", version=__version__)
875
+ parser.add_argument(
876
+ "--generate-completion",
877
+ choices=["bash", "zsh", "fish"],
878
+ help="Generate shell completion script and exit",
879
+ )
880
+
881
+ # Connection options
882
+ conn_group = parser.add_argument_group("connection options")
883
+ conn_group.add_argument("--host", "-H", type=str, help="Hostname / IP address to connect to")
884
+ conn_group.add_argument("--port", "-p", type=int, help="Port to connect to")
885
+ conn_group.add_argument("--path", type=str, help="Path, used for heating groups")
886
+ conn_group.add_argument("--username", "-U", nargs="?", help="Username required for access")
887
+ conn_group.add_argument("--password", "-P", nargs="?", help="Password required for access")
888
+ conn_group.add_argument("--tls", "-t", action="store_true", help="Enable TLS encryption")
889
+ conn_group.add_argument("--verify", action="store_true", help="Verify TLS encryption")
890
+ conn_group.add_argument("--json", "-j", action="store_true", help="Output as JSON")
891
+
892
+ # Subcommands
893
+ subparsers = parser.add_subparsers(dest="command", title="commands")
894
+
895
+ # list-devices
896
+ subparsers.add_parser("list-devices", help="List all devices")
897
+
898
+ # list-channels
899
+ parser_channels = subparsers.add_parser("list-channels", help="List channels of a device")
900
+ parser_channels.add_argument("device_address", help="Device address (e.g., VCU0000001)")
901
+
902
+ # list-parameters
903
+ parser_params = subparsers.add_parser("list-parameters", help="List parameters of a channel")
904
+ parser_params.add_argument("channel_address", help="Channel address (e.g., VCU0000001:1)")
905
+ parser_params.add_argument(
906
+ "--paramset_key",
907
+ "-k",
908
+ default="VALUES",
909
+ choices=["VALUES", "MASTER"],
910
+ help="Paramset key (default: VALUES)",
911
+ )
912
+
913
+ # device-info
914
+ parser_info = subparsers.add_parser("device-info", help="Show detailed device information")
915
+ parser_info.add_argument("device_address", help="Device or channel address")
916
+
917
+ # get
918
+ parser_get = subparsers.add_parser("get", help="Get parameter value")
919
+ parser_get.add_argument("--address", "-a", required=True, help="Channel address")
920
+ parser_get.add_argument("--parameter", "-n", required=True, help="Parameter name")
921
+ parser_get.add_argument(
922
+ "--paramset_key",
923
+ "-k",
924
+ default=ParamsetKey.VALUES,
925
+ choices=[ParamsetKey.VALUES, ParamsetKey.MASTER],
926
+ help="Paramset key (default: VALUES)",
927
+ )
928
+
929
+ # set
930
+ parser_set = subparsers.add_parser("set", help="Set parameter value")
931
+ parser_set.add_argument("--address", "-a", required=True, help="Channel address")
932
+ parser_set.add_argument("--parameter", "-n", required=True, help="Parameter name")
933
+ parser_set.add_argument("--value", "-v", required=True, help="Value to set")
934
+ parser_set.add_argument("--type", choices=["int", "float", "bool"], help="Value type")
935
+ parser_set.add_argument(
936
+ "--paramset_key",
937
+ "-k",
938
+ default=ParamsetKey.VALUES,
939
+ choices=[ParamsetKey.VALUES, ParamsetKey.MASTER],
940
+ help="Paramset key (default: VALUES)",
941
+ )
942
+
943
+ # interactive
944
+ subparsers.add_parser("interactive", help="Start interactive mode")
945
+
946
+ args = parser.parse_args()
947
+
948
+ # Handle shell completion generation
949
+ if args.generate_completion:
950
+ if args.generate_completion == "bash":
951
+ print(_BASH_COMPLETION)
952
+ elif args.generate_completion == "zsh":
953
+ print(_ZSH_COMPLETION)
954
+ elif args.generate_completion == "fish":
955
+ print(_FISH_COMPLETION)
956
+ sys.exit(0)
957
+
958
+ # Require host and port for all commands
959
+ if not args.command:
960
+ parser.print_help()
961
+ sys.exit(1)
962
+
963
+ if not args.host or not args.port:
964
+ print("Error: --host and --port are required", file=sys.stderr)
965
+ sys.exit(1)
966
+
967
+ # Create connection
968
+ connection = _HmCliConnection(
969
+ host=args.host,
970
+ port=args.port,
971
+ path=args.path,
972
+ username=args.username,
973
+ password=args.password,
974
+ tls=args.tls,
975
+ verify_tls=args.verify,
976
+ )
977
+
978
+ # Dispatch to command handler
979
+ handlers = {
980
+ "list-devices": _cmd_list_devices,
981
+ "list-channels": _cmd_list_channels,
982
+ "list-parameters": _cmd_list_parameters,
983
+ "device-info": _cmd_device_info,
984
+ "get": _cmd_get,
985
+ "set": _cmd_set,
986
+ "interactive": _cmd_interactive,
987
+ }
988
+
989
+ if handler := handlers.get(args.command):
990
+ sys.exit(handler(connection=connection, args=args))
991
+
992
+ parser.print_help()
993
+ sys.exit(1)
994
+
995
+
996
+ if __name__ == "__main__":
997
+ main()