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.
- aiohomematic/__init__.py +110 -0
- aiohomematic/_log_context_protocol.py +29 -0
- aiohomematic/api.py +410 -0
- aiohomematic/async_support.py +250 -0
- aiohomematic/backend_detection.py +462 -0
- aiohomematic/central/__init__.py +103 -0
- aiohomematic/central/async_rpc_server.py +760 -0
- aiohomematic/central/central_unit.py +1152 -0
- aiohomematic/central/config.py +463 -0
- aiohomematic/central/config_builder.py +772 -0
- aiohomematic/central/connection_state.py +160 -0
- aiohomematic/central/coordinators/__init__.py +38 -0
- aiohomematic/central/coordinators/cache.py +414 -0
- aiohomematic/central/coordinators/client.py +480 -0
- aiohomematic/central/coordinators/connection_recovery.py +1141 -0
- aiohomematic/central/coordinators/device.py +1166 -0
- aiohomematic/central/coordinators/event.py +514 -0
- aiohomematic/central/coordinators/hub.py +532 -0
- aiohomematic/central/decorators.py +184 -0
- aiohomematic/central/device_registry.py +229 -0
- aiohomematic/central/events/__init__.py +104 -0
- aiohomematic/central/events/bus.py +1392 -0
- aiohomematic/central/events/integration.py +424 -0
- aiohomematic/central/events/types.py +194 -0
- aiohomematic/central/health.py +762 -0
- aiohomematic/central/rpc_server.py +353 -0
- aiohomematic/central/scheduler.py +794 -0
- aiohomematic/central/state_machine.py +391 -0
- aiohomematic/client/__init__.py +203 -0
- aiohomematic/client/_rpc_errors.py +187 -0
- aiohomematic/client/backends/__init__.py +48 -0
- aiohomematic/client/backends/base.py +335 -0
- aiohomematic/client/backends/capabilities.py +138 -0
- aiohomematic/client/backends/ccu.py +487 -0
- aiohomematic/client/backends/factory.py +116 -0
- aiohomematic/client/backends/homegear.py +294 -0
- aiohomematic/client/backends/json_ccu.py +252 -0
- aiohomematic/client/backends/protocol.py +316 -0
- aiohomematic/client/ccu.py +1857 -0
- aiohomematic/client/circuit_breaker.py +459 -0
- aiohomematic/client/config.py +64 -0
- aiohomematic/client/handlers/__init__.py +40 -0
- aiohomematic/client/handlers/backup.py +157 -0
- aiohomematic/client/handlers/base.py +79 -0
- aiohomematic/client/handlers/device_ops.py +1085 -0
- aiohomematic/client/handlers/firmware.py +144 -0
- aiohomematic/client/handlers/link_mgmt.py +199 -0
- aiohomematic/client/handlers/metadata.py +436 -0
- aiohomematic/client/handlers/programs.py +144 -0
- aiohomematic/client/handlers/sysvars.py +100 -0
- aiohomematic/client/interface_client.py +1304 -0
- aiohomematic/client/json_rpc.py +2068 -0
- aiohomematic/client/request_coalescer.py +282 -0
- aiohomematic/client/rpc_proxy.py +629 -0
- aiohomematic/client/state_machine.py +324 -0
- aiohomematic/const.py +2207 -0
- aiohomematic/context.py +275 -0
- aiohomematic/converter.py +270 -0
- aiohomematic/decorators.py +390 -0
- aiohomematic/exceptions.py +185 -0
- aiohomematic/hmcli.py +997 -0
- aiohomematic/i18n.py +193 -0
- aiohomematic/interfaces/__init__.py +407 -0
- aiohomematic/interfaces/central.py +1067 -0
- aiohomematic/interfaces/client.py +1096 -0
- aiohomematic/interfaces/coordinators.py +63 -0
- aiohomematic/interfaces/model.py +1921 -0
- aiohomematic/interfaces/operations.py +217 -0
- aiohomematic/logging_context.py +134 -0
- aiohomematic/metrics/__init__.py +125 -0
- aiohomematic/metrics/_protocols.py +140 -0
- aiohomematic/metrics/aggregator.py +534 -0
- aiohomematic/metrics/dataclasses.py +489 -0
- aiohomematic/metrics/emitter.py +292 -0
- aiohomematic/metrics/events.py +183 -0
- aiohomematic/metrics/keys.py +300 -0
- aiohomematic/metrics/observer.py +563 -0
- aiohomematic/metrics/stats.py +172 -0
- aiohomematic/model/__init__.py +189 -0
- aiohomematic/model/availability.py +65 -0
- aiohomematic/model/calculated/__init__.py +89 -0
- aiohomematic/model/calculated/climate.py +276 -0
- aiohomematic/model/calculated/data_point.py +315 -0
- aiohomematic/model/calculated/field.py +147 -0
- aiohomematic/model/calculated/operating_voltage_level.py +286 -0
- aiohomematic/model/calculated/support.py +232 -0
- aiohomematic/model/custom/__init__.py +214 -0
- aiohomematic/model/custom/capabilities/__init__.py +67 -0
- aiohomematic/model/custom/capabilities/climate.py +41 -0
- aiohomematic/model/custom/capabilities/light.py +87 -0
- aiohomematic/model/custom/capabilities/lock.py +44 -0
- aiohomematic/model/custom/capabilities/siren.py +63 -0
- aiohomematic/model/custom/climate.py +1130 -0
- aiohomematic/model/custom/cover.py +722 -0
- aiohomematic/model/custom/data_point.py +360 -0
- aiohomematic/model/custom/definition.py +300 -0
- aiohomematic/model/custom/field.py +89 -0
- aiohomematic/model/custom/light.py +1174 -0
- aiohomematic/model/custom/lock.py +322 -0
- aiohomematic/model/custom/mixins.py +445 -0
- aiohomematic/model/custom/profile.py +945 -0
- aiohomematic/model/custom/registry.py +251 -0
- aiohomematic/model/custom/siren.py +462 -0
- aiohomematic/model/custom/switch.py +195 -0
- aiohomematic/model/custom/text_display.py +289 -0
- aiohomematic/model/custom/valve.py +78 -0
- aiohomematic/model/data_point.py +1416 -0
- aiohomematic/model/device.py +1840 -0
- aiohomematic/model/event.py +216 -0
- aiohomematic/model/generic/__init__.py +327 -0
- aiohomematic/model/generic/action.py +40 -0
- aiohomematic/model/generic/action_select.py +62 -0
- aiohomematic/model/generic/binary_sensor.py +30 -0
- aiohomematic/model/generic/button.py +31 -0
- aiohomematic/model/generic/data_point.py +177 -0
- aiohomematic/model/generic/dummy.py +150 -0
- aiohomematic/model/generic/number.py +76 -0
- aiohomematic/model/generic/select.py +56 -0
- aiohomematic/model/generic/sensor.py +76 -0
- aiohomematic/model/generic/switch.py +54 -0
- aiohomematic/model/generic/text.py +33 -0
- aiohomematic/model/hub/__init__.py +100 -0
- aiohomematic/model/hub/binary_sensor.py +24 -0
- aiohomematic/model/hub/button.py +28 -0
- aiohomematic/model/hub/connectivity.py +190 -0
- aiohomematic/model/hub/data_point.py +342 -0
- aiohomematic/model/hub/hub.py +864 -0
- aiohomematic/model/hub/inbox.py +135 -0
- aiohomematic/model/hub/install_mode.py +393 -0
- aiohomematic/model/hub/metrics.py +208 -0
- aiohomematic/model/hub/number.py +42 -0
- aiohomematic/model/hub/select.py +52 -0
- aiohomematic/model/hub/sensor.py +37 -0
- aiohomematic/model/hub/switch.py +43 -0
- aiohomematic/model/hub/text.py +30 -0
- aiohomematic/model/hub/update.py +221 -0
- aiohomematic/model/support.py +592 -0
- aiohomematic/model/update.py +140 -0
- aiohomematic/model/week_profile.py +1827 -0
- aiohomematic/property_decorators.py +719 -0
- aiohomematic/py.typed +0 -0
- aiohomematic/rega_scripts/accept_device_in_inbox.fn +51 -0
- aiohomematic/rega_scripts/create_backup_start.fn +28 -0
- aiohomematic/rega_scripts/create_backup_status.fn +89 -0
- aiohomematic/rega_scripts/fetch_all_device_data.fn +97 -0
- aiohomematic/rega_scripts/get_backend_info.fn +25 -0
- aiohomematic/rega_scripts/get_inbox_devices.fn +61 -0
- aiohomematic/rega_scripts/get_program_descriptions.fn +31 -0
- aiohomematic/rega_scripts/get_serial.fn +44 -0
- aiohomematic/rega_scripts/get_service_messages.fn +83 -0
- aiohomematic/rega_scripts/get_system_update_info.fn +39 -0
- aiohomematic/rega_scripts/get_system_variable_descriptions.fn +31 -0
- aiohomematic/rega_scripts/set_program_state.fn +17 -0
- aiohomematic/rega_scripts/set_system_variable.fn +19 -0
- aiohomematic/rega_scripts/trigger_firmware_update.fn +67 -0
- aiohomematic/schemas.py +256 -0
- aiohomematic/store/__init__.py +55 -0
- aiohomematic/store/dynamic/__init__.py +43 -0
- aiohomematic/store/dynamic/command.py +250 -0
- aiohomematic/store/dynamic/data.py +175 -0
- aiohomematic/store/dynamic/details.py +187 -0
- aiohomematic/store/dynamic/ping_pong.py +416 -0
- aiohomematic/store/persistent/__init__.py +71 -0
- aiohomematic/store/persistent/base.py +285 -0
- aiohomematic/store/persistent/device.py +233 -0
- aiohomematic/store/persistent/incident.py +380 -0
- aiohomematic/store/persistent/paramset.py +241 -0
- aiohomematic/store/persistent/session.py +556 -0
- aiohomematic/store/serialization.py +150 -0
- aiohomematic/store/storage.py +689 -0
- aiohomematic/store/types.py +526 -0
- aiohomematic/store/visibility/__init__.py +40 -0
- aiohomematic/store/visibility/parser.py +141 -0
- aiohomematic/store/visibility/registry.py +722 -0
- aiohomematic/store/visibility/rules.py +307 -0
- aiohomematic/strings.json +237 -0
- aiohomematic/support.py +706 -0
- aiohomematic/tracing.py +236 -0
- aiohomematic/translations/de.json +237 -0
- aiohomematic/translations/en.json +237 -0
- aiohomematic/type_aliases.py +51 -0
- aiohomematic/validator.py +128 -0
- aiohomematic-2026.1.29.dist-info/METADATA +296 -0
- aiohomematic-2026.1.29.dist-info/RECORD +188 -0
- aiohomematic-2026.1.29.dist-info/WHEEL +5 -0
- aiohomematic-2026.1.29.dist-info/entry_points.txt +2 -0
- aiohomematic-2026.1.29.dist-info/licenses/LICENSE +21 -0
- 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()
|