aiohomematic 2025.8.6__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.
Potentially problematic release.
This version of aiohomematic might be problematic. Click here for more details.
- aiohomematic/__init__.py +47 -0
- aiohomematic/async_support.py +146 -0
- aiohomematic/caches/__init__.py +10 -0
- aiohomematic/caches/dynamic.py +554 -0
- aiohomematic/caches/persistent.py +459 -0
- aiohomematic/caches/visibility.py +774 -0
- aiohomematic/central/__init__.py +2034 -0
- aiohomematic/central/decorators.py +110 -0
- aiohomematic/central/xml_rpc_server.py +267 -0
- aiohomematic/client/__init__.py +1746 -0
- aiohomematic/client/json_rpc.py +1193 -0
- aiohomematic/client/xml_rpc.py +222 -0
- aiohomematic/const.py +795 -0
- aiohomematic/context.py +8 -0
- aiohomematic/converter.py +82 -0
- aiohomematic/decorators.py +188 -0
- aiohomematic/exceptions.py +145 -0
- aiohomematic/hmcli.py +159 -0
- aiohomematic/model/__init__.py +137 -0
- aiohomematic/model/calculated/__init__.py +65 -0
- aiohomematic/model/calculated/climate.py +230 -0
- aiohomematic/model/calculated/data_point.py +319 -0
- aiohomematic/model/calculated/operating_voltage_level.py +311 -0
- aiohomematic/model/calculated/support.py +174 -0
- aiohomematic/model/custom/__init__.py +175 -0
- aiohomematic/model/custom/climate.py +1334 -0
- aiohomematic/model/custom/const.py +146 -0
- aiohomematic/model/custom/cover.py +741 -0
- aiohomematic/model/custom/data_point.py +318 -0
- aiohomematic/model/custom/definition.py +861 -0
- aiohomematic/model/custom/light.py +1092 -0
- aiohomematic/model/custom/lock.py +389 -0
- aiohomematic/model/custom/siren.py +268 -0
- aiohomematic/model/custom/support.py +40 -0
- aiohomematic/model/custom/switch.py +172 -0
- aiohomematic/model/custom/valve.py +112 -0
- aiohomematic/model/data_point.py +1109 -0
- aiohomematic/model/decorators.py +173 -0
- aiohomematic/model/device.py +1347 -0
- aiohomematic/model/event.py +210 -0
- aiohomematic/model/generic/__init__.py +211 -0
- aiohomematic/model/generic/action.py +32 -0
- aiohomematic/model/generic/binary_sensor.py +28 -0
- aiohomematic/model/generic/button.py +25 -0
- aiohomematic/model/generic/data_point.py +162 -0
- aiohomematic/model/generic/number.py +73 -0
- aiohomematic/model/generic/select.py +36 -0
- aiohomematic/model/generic/sensor.py +72 -0
- aiohomematic/model/generic/switch.py +52 -0
- aiohomematic/model/generic/text.py +27 -0
- aiohomematic/model/hub/__init__.py +334 -0
- aiohomematic/model/hub/binary_sensor.py +22 -0
- aiohomematic/model/hub/button.py +26 -0
- aiohomematic/model/hub/data_point.py +332 -0
- aiohomematic/model/hub/number.py +37 -0
- aiohomematic/model/hub/select.py +47 -0
- aiohomematic/model/hub/sensor.py +35 -0
- aiohomematic/model/hub/switch.py +42 -0
- aiohomematic/model/hub/text.py +28 -0
- aiohomematic/model/support.py +599 -0
- aiohomematic/model/update.py +136 -0
- aiohomematic/py.typed +0 -0
- aiohomematic/rega_scripts/fetch_all_device_data.fn +75 -0
- aiohomematic/rega_scripts/get_program_descriptions.fn +30 -0
- aiohomematic/rega_scripts/get_serial.fn +44 -0
- aiohomematic/rega_scripts/get_system_variable_descriptions.fn +30 -0
- aiohomematic/rega_scripts/set_program_state.fn +12 -0
- aiohomematic/rega_scripts/set_system_variable.fn +15 -0
- aiohomematic/support.py +482 -0
- aiohomematic/validator.py +65 -0
- aiohomematic-2025.8.6.dist-info/METADATA +69 -0
- aiohomematic-2025.8.6.dist-info/RECORD +77 -0
- aiohomematic-2025.8.6.dist-info/WHEEL +5 -0
- aiohomematic-2025.8.6.dist-info/licenses/LICENSE +21 -0
- aiohomematic-2025.8.6.dist-info/top_level.txt +2 -0
- aiohomematic_support/__init__.py +1 -0
- aiohomematic_support/client_local.py +349 -0
aiohomematic/support.py
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
"""Helper functions used within aiohomematic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from collections.abc import Callable, Collection, Set as AbstractSet
|
|
8
|
+
import contextlib
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
import hashlib
|
|
12
|
+
from ipaddress import IPv4Address
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import socket
|
|
17
|
+
import ssl
|
|
18
|
+
import sys
|
|
19
|
+
from typing import Any, Final, cast
|
|
20
|
+
|
|
21
|
+
from aiohomematic import client as hmcl
|
|
22
|
+
from aiohomematic.const import (
|
|
23
|
+
ADDRESS_SEPARATOR,
|
|
24
|
+
ALLOWED_HOSTNAME_PATTERN,
|
|
25
|
+
CCU_PASSWORD_PATTERN,
|
|
26
|
+
CHANNEL_ADDRESS_PATTERN,
|
|
27
|
+
DEVICE_ADDRESS_PATTERN,
|
|
28
|
+
HTMLTAG_PATTERN,
|
|
29
|
+
IDENTIFIER_SEPARATOR,
|
|
30
|
+
INIT_DATETIME,
|
|
31
|
+
ISO_8859_1,
|
|
32
|
+
MAX_CACHE_AGE,
|
|
33
|
+
NO_CACHE_ENTRY,
|
|
34
|
+
PRIMARY_CLIENT_CANDIDATE_INTERFACES,
|
|
35
|
+
TIMEOUT,
|
|
36
|
+
CommandRxMode,
|
|
37
|
+
ParamsetKey,
|
|
38
|
+
RxMode,
|
|
39
|
+
SysvarType,
|
|
40
|
+
)
|
|
41
|
+
from aiohomematic.exceptions import AioHomematicException, BaseHomematicException
|
|
42
|
+
|
|
43
|
+
_LOGGER: Final = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def extract_exc_args(exc: Exception) -> tuple[Any, ...] | Any:
|
|
47
|
+
"""Return the first arg, if there is only one arg."""
|
|
48
|
+
if exc.args:
|
|
49
|
+
return exc.args[0] if len(exc.args) == 1 else exc.args
|
|
50
|
+
return exc
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build_xml_rpc_uri(
|
|
54
|
+
host: str,
|
|
55
|
+
port: int | None,
|
|
56
|
+
path: str | None,
|
|
57
|
+
tls: bool = False,
|
|
58
|
+
) -> str:
|
|
59
|
+
"""Build XML-RPC API URL from components."""
|
|
60
|
+
scheme = "http"
|
|
61
|
+
s_port = f":{port}" if port else ""
|
|
62
|
+
if not path:
|
|
63
|
+
path = ""
|
|
64
|
+
if path and not path.startswith("/"):
|
|
65
|
+
path = f"/{path}"
|
|
66
|
+
if tls:
|
|
67
|
+
scheme += "s"
|
|
68
|
+
return f"{scheme}://{host}{s_port}{path}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def build_xml_rpc_headers(
|
|
72
|
+
username: str,
|
|
73
|
+
password: str,
|
|
74
|
+
) -> list[tuple[str, str]]:
|
|
75
|
+
"""Build XML-RPC API header."""
|
|
76
|
+
cred_bytes = f"{username}:{password}".encode()
|
|
77
|
+
base64_message = base64.b64encode(cred_bytes).decode(ISO_8859_1)
|
|
78
|
+
return [("Authorization", f"Basic {base64_message}")]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def check_config(
|
|
82
|
+
central_name: str,
|
|
83
|
+
host: str,
|
|
84
|
+
username: str,
|
|
85
|
+
password: str,
|
|
86
|
+
storage_folder: str,
|
|
87
|
+
callback_host: str | None,
|
|
88
|
+
callback_port: int | None,
|
|
89
|
+
json_port: int | None,
|
|
90
|
+
interface_configs: AbstractSet[hmcl.InterfaceConfig] | None = None,
|
|
91
|
+
) -> list[str]:
|
|
92
|
+
"""Check config. Throws BaseHomematicException on failure."""
|
|
93
|
+
config_failures: list[str] = []
|
|
94
|
+
if central_name and IDENTIFIER_SEPARATOR in central_name:
|
|
95
|
+
config_failures.append(f"Instance name must not contain {IDENTIFIER_SEPARATOR}")
|
|
96
|
+
|
|
97
|
+
if not (is_hostname(hostname=host) or is_ipv4_address(address=host)):
|
|
98
|
+
config_failures.append("Invalid hostname or ipv4 address")
|
|
99
|
+
if not username:
|
|
100
|
+
config_failures.append("Username must not be empty")
|
|
101
|
+
if not password:
|
|
102
|
+
config_failures.append("Password is required")
|
|
103
|
+
if not check_password(password):
|
|
104
|
+
config_failures.append("Password is not valid")
|
|
105
|
+
try:
|
|
106
|
+
check_or_create_directory(storage_folder)
|
|
107
|
+
except BaseHomematicException as bhexc:
|
|
108
|
+
config_failures.append(extract_exc_args(exc=bhexc)[0])
|
|
109
|
+
if callback_host and not (is_hostname(hostname=callback_host) or is_ipv4_address(address=callback_host)):
|
|
110
|
+
config_failures.append("Invalid callback hostname or ipv4 address")
|
|
111
|
+
if callback_port and not is_port(port=callback_port):
|
|
112
|
+
config_failures.append("Invalid callback port")
|
|
113
|
+
if json_port and not is_port(port=json_port):
|
|
114
|
+
config_failures.append("Invalid json port")
|
|
115
|
+
if interface_configs and not has_primary_client(interface_configs=interface_configs):
|
|
116
|
+
config_failures.append(f"No primary interface ({', '.join(PRIMARY_CLIENT_CANDIDATE_INTERFACES)}) defined")
|
|
117
|
+
|
|
118
|
+
return config_failures
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def has_primary_client(interface_configs: AbstractSet[hmcl.InterfaceConfig]) -> bool:
|
|
122
|
+
"""Check if all configured clients exists in central."""
|
|
123
|
+
for interface_config in interface_configs:
|
|
124
|
+
if interface_config.interface in PRIMARY_CLIENT_CANDIDATE_INTERFACES:
|
|
125
|
+
return True
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def delete_file(folder: str, file_name: str) -> None:
|
|
130
|
+
"""Delete the file."""
|
|
131
|
+
file_path = os.path.join(folder, file_name)
|
|
132
|
+
if (
|
|
133
|
+
os.path.exists(folder)
|
|
134
|
+
and os.path.exists(file_path)
|
|
135
|
+
and (os.path.isfile(file_path) or os.path.islink(file_path))
|
|
136
|
+
):
|
|
137
|
+
os.unlink(file_path)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def check_or_create_directory(directory: str) -> bool:
|
|
141
|
+
"""Check / create directory."""
|
|
142
|
+
if not directory:
|
|
143
|
+
return False
|
|
144
|
+
if not os.path.exists(directory):
|
|
145
|
+
try:
|
|
146
|
+
os.makedirs(directory)
|
|
147
|
+
except OSError as oserr:
|
|
148
|
+
raise AioHomematicException(
|
|
149
|
+
f"CHECK_OR_CREATE_DIRECTORY failed: Unable to create directory {directory} ('{oserr.strerror}')"
|
|
150
|
+
) from oserr
|
|
151
|
+
return True
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def parse_sys_var(data_type: SysvarType | None, raw_value: Any) -> Any:
|
|
155
|
+
"""Parse system variables to fix type."""
|
|
156
|
+
if not data_type:
|
|
157
|
+
return raw_value
|
|
158
|
+
if data_type in (SysvarType.ALARM, SysvarType.LOGIC):
|
|
159
|
+
return to_bool(raw_value)
|
|
160
|
+
if data_type == SysvarType.FLOAT:
|
|
161
|
+
return float(raw_value)
|
|
162
|
+
if data_type in (SysvarType.INTEGER, SysvarType.LIST):
|
|
163
|
+
return int(raw_value)
|
|
164
|
+
return raw_value
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def to_bool(value: Any) -> bool:
|
|
168
|
+
"""Convert defined string values to bool."""
|
|
169
|
+
if isinstance(value, bool):
|
|
170
|
+
return value
|
|
171
|
+
|
|
172
|
+
if not isinstance(value, str):
|
|
173
|
+
raise TypeError("invalid literal for boolean. Not a string.")
|
|
174
|
+
|
|
175
|
+
return value.lower() in ["y", "yes", "t", "true", "on", "1"]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def check_password(password: str | None) -> bool:
|
|
179
|
+
"""Check password."""
|
|
180
|
+
if password is None:
|
|
181
|
+
return False
|
|
182
|
+
if CCU_PASSWORD_PATTERN.fullmatch(password) is None:
|
|
183
|
+
_LOGGER.warning(
|
|
184
|
+
"CHECK_CONFIG: password contains not allowed characters. "
|
|
185
|
+
"Use only allowed characters. See password regex: %s",
|
|
186
|
+
CCU_PASSWORD_PATTERN.pattern,
|
|
187
|
+
)
|
|
188
|
+
return False
|
|
189
|
+
return True
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def regular_to_default_dict_hook(origin: dict) -> defaultdict[Any, Any]:
|
|
193
|
+
"""Use defaultdict in json.loads object_hook."""
|
|
194
|
+
new_dict: Callable = lambda: defaultdict(new_dict)
|
|
195
|
+
new_instance = new_dict()
|
|
196
|
+
new_instance.update(origin)
|
|
197
|
+
return cast(defaultdict[Any, Any], new_instance)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _create_tls_context(verify_tls: bool) -> ssl.SSLContext:
|
|
201
|
+
"""Create tls verified/unverified context."""
|
|
202
|
+
sslcontext = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
203
|
+
if not verify_tls:
|
|
204
|
+
sslcontext.check_hostname = False
|
|
205
|
+
sslcontext.verify_mode = ssl.CERT_NONE
|
|
206
|
+
with contextlib.suppress(AttributeError):
|
|
207
|
+
# This only works for OpenSSL >= 1.0.0
|
|
208
|
+
sslcontext.options |= ssl.OP_NO_COMPRESSION
|
|
209
|
+
sslcontext.set_default_verify_paths()
|
|
210
|
+
return sslcontext
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
_DEFAULT_NO_VERIFY_SSL_CONTEXT = _create_tls_context(verify_tls=False)
|
|
214
|
+
_DEFAULT_SSL_CONTEXT = _create_tls_context(verify_tls=True)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def get_tls_context(verify_tls: bool) -> ssl.SSLContext:
|
|
218
|
+
"""Return tls verified/unverified context."""
|
|
219
|
+
return _DEFAULT_SSL_CONTEXT if verify_tls else _DEFAULT_NO_VERIFY_SSL_CONTEXT
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def get_channel_address(device_address: str, channel_no: int | None) -> str:
|
|
223
|
+
"""Return the channel address."""
|
|
224
|
+
return device_address if channel_no is None else f"{device_address}:{channel_no}"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def get_device_address(address: str) -> str:
|
|
228
|
+
"""Return the device part of an address."""
|
|
229
|
+
return get_split_channel_address(channel_address=address)[0]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def get_channel_no(address: str) -> int | None:
|
|
233
|
+
"""Return the channel part of an address."""
|
|
234
|
+
return get_split_channel_address(channel_address=address)[1]
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def is_address(address: str) -> bool:
|
|
238
|
+
"""Check if it is a address."""
|
|
239
|
+
return is_device_address(address=address) or is_channel_address(address=address)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def is_channel_address(address: str) -> bool:
|
|
243
|
+
"""Check if it is a channel address."""
|
|
244
|
+
return CHANNEL_ADDRESS_PATTERN.match(address) is not None
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def is_device_address(address: str) -> bool:
|
|
248
|
+
"""Check if it is a device address."""
|
|
249
|
+
return DEVICE_ADDRESS_PATTERN.match(address) is not None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def is_paramset_key(paramset_key: ParamsetKey | str) -> bool:
|
|
253
|
+
"""Check if it is a paramset key."""
|
|
254
|
+
return isinstance(paramset_key, ParamsetKey) or (isinstance(paramset_key, str) and paramset_key in ParamsetKey)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def get_split_channel_address(channel_address: str) -> tuple[str, int | None]:
|
|
258
|
+
"""Return the device part of an address."""
|
|
259
|
+
if ADDRESS_SEPARATOR in channel_address:
|
|
260
|
+
device_address, channel_no = channel_address.split(ADDRESS_SEPARATOR)
|
|
261
|
+
if channel_no in (None, "None"):
|
|
262
|
+
return device_address, None
|
|
263
|
+
return device_address, int(channel_no)
|
|
264
|
+
return channel_address, None
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def changed_within_seconds(last_change: datetime, max_age: int = MAX_CACHE_AGE) -> bool:
|
|
268
|
+
"""DataPoint has been modified within X minutes."""
|
|
269
|
+
if last_change == INIT_DATETIME:
|
|
270
|
+
return False
|
|
271
|
+
delta = datetime.now() - last_change
|
|
272
|
+
return delta.seconds < max_age
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def find_free_port() -> int:
|
|
276
|
+
"""Find a free port for XmlRpc server default port."""
|
|
277
|
+
with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:
|
|
278
|
+
sock.bind(("", 0))
|
|
279
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
280
|
+
return int(sock.getsockname()[1])
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def get_ip_addr(host: str, port: int) -> str | None:
|
|
284
|
+
"""Get local_ip from socket."""
|
|
285
|
+
try:
|
|
286
|
+
socket.gethostbyname(host)
|
|
287
|
+
except Exception as exc:
|
|
288
|
+
raise AioHomematicException(
|
|
289
|
+
f"GET_LOCAL_IP: Can't resolve host for {host}:{port}: {extract_exc_args(exc=exc)}"
|
|
290
|
+
) from exc
|
|
291
|
+
tmp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
292
|
+
tmp_socket.settimeout(TIMEOUT)
|
|
293
|
+
tmp_socket.connect((host, port))
|
|
294
|
+
local_ip = str(tmp_socket.getsockname()[0])
|
|
295
|
+
tmp_socket.close()
|
|
296
|
+
_LOGGER.debug("GET_LOCAL_IP: Got local ip: %s", local_ip)
|
|
297
|
+
return local_ip
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def is_hostname(hostname: str | None) -> bool:
|
|
301
|
+
"""Return True if hostname is valid."""
|
|
302
|
+
if not hostname:
|
|
303
|
+
return False
|
|
304
|
+
if hostname[-1] == ".":
|
|
305
|
+
# strip exactly one dot from the right, if present
|
|
306
|
+
hostname = hostname[:-1]
|
|
307
|
+
if len(hostname) > 253 or len(hostname) < 1:
|
|
308
|
+
return False
|
|
309
|
+
|
|
310
|
+
labels = hostname.split(".")
|
|
311
|
+
|
|
312
|
+
# the TLD must be not all-numeric
|
|
313
|
+
if re.match(r"[0-9]+$", labels[-1]):
|
|
314
|
+
return False
|
|
315
|
+
|
|
316
|
+
return all(ALLOWED_HOSTNAME_PATTERN.match(label) for label in labels)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def is_ipv4_address(address: str | None) -> bool:
|
|
320
|
+
"""Return True if ipv4_address is valid."""
|
|
321
|
+
if not address:
|
|
322
|
+
return False
|
|
323
|
+
try:
|
|
324
|
+
IPv4Address(address=address)
|
|
325
|
+
except ValueError:
|
|
326
|
+
return False
|
|
327
|
+
return True
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def is_port(port: int) -> bool:
|
|
331
|
+
"""Return True if port is valid."""
|
|
332
|
+
return 0 <= port <= 65535
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def element_matches_key(
|
|
336
|
+
search_elements: str | Collection[str],
|
|
337
|
+
compare_with: str | None,
|
|
338
|
+
search_key: str | None = None,
|
|
339
|
+
ignore_case: bool = True,
|
|
340
|
+
do_left_wildcard_search: bool = False,
|
|
341
|
+
do_right_wildcard_search: bool = True,
|
|
342
|
+
) -> bool:
|
|
343
|
+
"""
|
|
344
|
+
Return if collection element is key.
|
|
345
|
+
|
|
346
|
+
Default search uses a right wildcard.
|
|
347
|
+
A set search_key assumes that search_elements is initially a dict,
|
|
348
|
+
and it tries to identify a matching key (wildcard) in the dict keys to use it on the dict.
|
|
349
|
+
"""
|
|
350
|
+
if compare_with is None or not search_elements:
|
|
351
|
+
return False
|
|
352
|
+
|
|
353
|
+
compare_with = compare_with.lower() if ignore_case else compare_with
|
|
354
|
+
|
|
355
|
+
if isinstance(search_elements, str):
|
|
356
|
+
element = search_elements.lower() if ignore_case else search_elements
|
|
357
|
+
if do_left_wildcard_search is True and do_right_wildcard_search is True:
|
|
358
|
+
return element in compare_with
|
|
359
|
+
if do_left_wildcard_search:
|
|
360
|
+
return compare_with.endswith(element)
|
|
361
|
+
if do_right_wildcard_search:
|
|
362
|
+
return compare_with.startswith(element)
|
|
363
|
+
return compare_with == element
|
|
364
|
+
if isinstance(search_elements, Collection):
|
|
365
|
+
if isinstance(search_elements, dict) and (
|
|
366
|
+
match_key := _get_search_key(search_elements=search_elements, search_key=search_key) if search_key else None
|
|
367
|
+
):
|
|
368
|
+
if (elements := search_elements.get(match_key)) is None:
|
|
369
|
+
return False
|
|
370
|
+
search_elements = elements
|
|
371
|
+
for item in search_elements:
|
|
372
|
+
element = item.lower() if ignore_case else item
|
|
373
|
+
if do_left_wildcard_search is True and do_right_wildcard_search is True:
|
|
374
|
+
if element in compare_with:
|
|
375
|
+
return True
|
|
376
|
+
elif do_left_wildcard_search:
|
|
377
|
+
if compare_with.endswith(element):
|
|
378
|
+
return True
|
|
379
|
+
elif do_right_wildcard_search:
|
|
380
|
+
if compare_with.startswith(element):
|
|
381
|
+
return True
|
|
382
|
+
elif compare_with == element:
|
|
383
|
+
return True
|
|
384
|
+
return False
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _get_search_key(search_elements: Collection[str], search_key: str) -> str | None:
|
|
388
|
+
"""Search for a matching key in a collection."""
|
|
389
|
+
for element in search_elements:
|
|
390
|
+
if search_key.startswith(element):
|
|
391
|
+
return element
|
|
392
|
+
return None
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
@dataclass(frozen=True, kw_only=True, slots=True)
|
|
396
|
+
class CacheEntry:
|
|
397
|
+
"""An entry for the value cache."""
|
|
398
|
+
|
|
399
|
+
value: Any
|
|
400
|
+
refresh_at: datetime
|
|
401
|
+
|
|
402
|
+
@staticmethod
|
|
403
|
+
def empty() -> CacheEntry:
|
|
404
|
+
"""Return empty cache entry."""
|
|
405
|
+
return CacheEntry(value=NO_CACHE_ENTRY, refresh_at=datetime.min)
|
|
406
|
+
|
|
407
|
+
@property
|
|
408
|
+
def is_valid(self) -> bool:
|
|
409
|
+
"""Return if entry is valid."""
|
|
410
|
+
if self.value == NO_CACHE_ENTRY:
|
|
411
|
+
return False
|
|
412
|
+
return changed_within_seconds(last_change=self.refresh_at)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def debug_enabled() -> bool:
|
|
416
|
+
"""Check if debug mode is enabled."""
|
|
417
|
+
try:
|
|
418
|
+
if sys.gettrace() is not None:
|
|
419
|
+
return True
|
|
420
|
+
except AttributeError:
|
|
421
|
+
pass
|
|
422
|
+
|
|
423
|
+
try:
|
|
424
|
+
if sys.monitoring.get_tool(sys.monitoring.DEBUGGER_ID) is not None:
|
|
425
|
+
return True
|
|
426
|
+
except AttributeError:
|
|
427
|
+
pass
|
|
428
|
+
|
|
429
|
+
return False
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def hash_sha256(value: Any) -> str:
|
|
433
|
+
"""Hash a value with sha256."""
|
|
434
|
+
hasher = hashlib.sha256()
|
|
435
|
+
hasher.update(repr(_make_value_hashable(value)).encode())
|
|
436
|
+
return base64.b64encode(hasher.digest()).decode()
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _make_value_hashable(value: Any) -> Any:
|
|
440
|
+
"""Make a hashable object."""
|
|
441
|
+
if isinstance(value, (tuple, list)):
|
|
442
|
+
return tuple(_make_value_hashable(e) for e in value)
|
|
443
|
+
|
|
444
|
+
if isinstance(value, dict):
|
|
445
|
+
return tuple(sorted((k, _make_value_hashable(v)) for k, v in value.items()))
|
|
446
|
+
|
|
447
|
+
if isinstance(value, (set, frozenset)):
|
|
448
|
+
return tuple(sorted(_make_value_hashable(e) for e in value))
|
|
449
|
+
|
|
450
|
+
return value
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def get_rx_modes(mode: int) -> tuple[RxMode, ...]:
|
|
454
|
+
"""Convert int to rx modes."""
|
|
455
|
+
rx_modes: set[RxMode] = set()
|
|
456
|
+
if mode & RxMode.LAZY_CONFIG:
|
|
457
|
+
mode -= RxMode.LAZY_CONFIG
|
|
458
|
+
rx_modes.add(RxMode.LAZY_CONFIG)
|
|
459
|
+
if mode & RxMode.WAKEUP:
|
|
460
|
+
mode -= RxMode.WAKEUP
|
|
461
|
+
rx_modes.add(RxMode.WAKEUP)
|
|
462
|
+
if mode & RxMode.CONFIG:
|
|
463
|
+
mode -= RxMode.CONFIG
|
|
464
|
+
rx_modes.add(RxMode.CONFIG)
|
|
465
|
+
if mode & RxMode.BURST:
|
|
466
|
+
mode -= RxMode.BURST
|
|
467
|
+
rx_modes.add(RxMode.BURST)
|
|
468
|
+
if mode & RxMode.ALWAYS:
|
|
469
|
+
rx_modes.add(RxMode.ALWAYS)
|
|
470
|
+
return tuple(rx_modes)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def supports_rx_mode(command_rx_mode: CommandRxMode, rx_modes: tuple[RxMode, ...]) -> bool:
|
|
474
|
+
"""Check if rx mode is supported."""
|
|
475
|
+
return (command_rx_mode == CommandRxMode.BURST and RxMode.BURST in rx_modes) or (
|
|
476
|
+
command_rx_mode == CommandRxMode.WAKEUP and RxMode.WAKEUP in rx_modes
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def cleanup_text_from_html_tags(text: str) -> str:
|
|
481
|
+
"""Cleanup text from html tags."""
|
|
482
|
+
return re.sub(HTMLTAG_PATTERN, "", text)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Validator functions used within aiohomematic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import voluptuous as vol
|
|
6
|
+
|
|
7
|
+
from aiohomematic.const import MAX_WAIT_FOR_CALLBACK
|
|
8
|
+
from aiohomematic.support import (
|
|
9
|
+
check_password,
|
|
10
|
+
is_channel_address,
|
|
11
|
+
is_device_address,
|
|
12
|
+
is_hostname,
|
|
13
|
+
is_ipv4_address,
|
|
14
|
+
is_paramset_key,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
channel_no = vol.All(vol.Coerce(int), vol.Range(min=0, max=999))
|
|
18
|
+
positive_int = vol.All(vol.Coerce(int), vol.Range(min=0))
|
|
19
|
+
wait_for = vol.All(vol.Coerce(int), vol.Range(min=1, max=MAX_WAIT_FOR_CALLBACK))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def channel_address(value: str) -> str:
|
|
23
|
+
"""Validate channel_address."""
|
|
24
|
+
if is_channel_address(address=value):
|
|
25
|
+
return value
|
|
26
|
+
raise vol.Invalid("channel_address is invalid")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def device_address(value: str) -> str:
|
|
30
|
+
"""Validate channel_address."""
|
|
31
|
+
if is_device_address(address=value):
|
|
32
|
+
return value
|
|
33
|
+
raise vol.Invalid("device_address is invalid")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def hostname(value: str) -> str:
|
|
37
|
+
"""Validate hostname."""
|
|
38
|
+
if is_hostname(hostname=value):
|
|
39
|
+
return value
|
|
40
|
+
raise vol.Invalid("hostname is invalid")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def ipv4_address(value: str) -> str:
|
|
44
|
+
"""Validate ipv4_address."""
|
|
45
|
+
if is_ipv4_address(address=value):
|
|
46
|
+
return value
|
|
47
|
+
raise vol.Invalid("ipv4_address is invalid")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def password(value: str) -> str:
|
|
51
|
+
"""Validate password."""
|
|
52
|
+
if check_password(password=value):
|
|
53
|
+
return value
|
|
54
|
+
raise vol.Invalid("password is invalid")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def paramset_key(value: str) -> str:
|
|
58
|
+
"""Validate paramset_key."""
|
|
59
|
+
if is_paramset_key(paramset_key=value):
|
|
60
|
+
return value
|
|
61
|
+
raise vol.Invalid("paramset_key is invalid")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
address = vol.All(vol.Coerce(str), vol.Any(device_address, channel_address))
|
|
65
|
+
host = vol.All(vol.Coerce(str), vol.Any(hostname, ipv4_address))
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aiohomematic
|
|
3
|
+
Version: 2025.8.6
|
|
4
|
+
Summary: Homematic interface for Home Assistant running on Python 3.
|
|
5
|
+
Home-page: https://github.com/sukramj/aiohomematic
|
|
6
|
+
Author-email: SukramJ <sukramj@icloud.com>, Daniel Perna <danielperna84@gmail.com>
|
|
7
|
+
License: MIT License
|
|
8
|
+
Project-URL: Source Code, https://github.com/sukramj/aiohomematic
|
|
9
|
+
Project-URL: Bug Reports, https://github.com/sukramj/aiohomematic/issues
|
|
10
|
+
Project-URL: Docs: Dev, https://github.com/sukramj/aiohomematic
|
|
11
|
+
Project-URL: Forum, https://github.com/sukramj/aiohomematic/discussions
|
|
12
|
+
Keywords: home,automation,homematic
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Home Automation
|
|
20
|
+
Requires-Python: >=3.13.0
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: aiohttp>=3.10.0
|
|
24
|
+
Requires-Dist: orjson>=3.10.0
|
|
25
|
+
Requires-Dist: python-slugify>=8.0.0
|
|
26
|
+
Requires-Dist: voluptuous>=0.14.0
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# AIO Homematic (hahomematic)
|
|
30
|
+
|
|
31
|
+
A lightweight Python 3 library that powers Home Assistant integrations for controlling and monitoring [HomeMatic](https://www.eq-3.com/products/homematic.html) and [HomematicIP](https://www.homematic-ip.com/en/start.html) devices. Some third‑party devices/gateways (e.g., Bosch, Intertechno) may be supported as well.
|
|
32
|
+
|
|
33
|
+
This project is the modern successor to [pyhomematic](https://github.com/danielperna84/pyhomematic), focusing on automatic entity creation, fewer manual device definitions, and faster startups.
|
|
34
|
+
|
|
35
|
+
## How it works
|
|
36
|
+
|
|
37
|
+
Unlike pyhomematic, which required manual device mappings, aiohomematic automatically creates entities for each relevant parameter on every device channel (unless blacklisted). To achieve this it:
|
|
38
|
+
|
|
39
|
+
- Fetches and caches device paramsets (VALUES) for fast successive startups.
|
|
40
|
+
- Provides hooks for custom entity classes where complex behavior is needed (e.g., thermostats, lights, covers, climate, locks, sirens).
|
|
41
|
+
- Includes helpers for robust operation, such as automatic reconnection after CCU restarts.
|
|
42
|
+
|
|
43
|
+
## Key features
|
|
44
|
+
|
|
45
|
+
- Automatic entity discovery from device/channel parameters.
|
|
46
|
+
- Extensible via custom entity classes for complex devices.
|
|
47
|
+
- Caching of paramsets to speed up restarts.
|
|
48
|
+
- Designed to integrate with Home Assistant.
|
|
49
|
+
|
|
50
|
+
## Installation (with Home Assistant)
|
|
51
|
+
|
|
52
|
+
Install via the custom component: [custom_homematic](https://github.com/sukramj/custom_homematic).
|
|
53
|
+
|
|
54
|
+
Follow the installation guide: https://github.com/sukramj/custom_homematic/wiki/Installation
|
|
55
|
+
|
|
56
|
+
## Requirements
|
|
57
|
+
|
|
58
|
+
Due to a bug in earlier CCU2/CCU3 firmware, aiohomematic requires at least the following versions when used with HomematicIP devices:
|
|
59
|
+
|
|
60
|
+
- CCU2: 2.53.27
|
|
61
|
+
- CCU3: 3.53.26
|
|
62
|
+
|
|
63
|
+
See details here: https://github.com/jens-maus/RaspberryMatic/issues/843. Other CCU‑like platforms using the buggy HmIPServer version are not supported.
|
|
64
|
+
|
|
65
|
+
## Useful links
|
|
66
|
+
|
|
67
|
+
- Examples: see example.py in this repository.
|
|
68
|
+
- Changelog: see changelog.md.
|
|
69
|
+
- Source code and documentation: this repository (docs/ directory may contain additional information).
|