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.

Files changed (77) hide show
  1. aiohomematic/__init__.py +47 -0
  2. aiohomematic/async_support.py +146 -0
  3. aiohomematic/caches/__init__.py +10 -0
  4. aiohomematic/caches/dynamic.py +554 -0
  5. aiohomematic/caches/persistent.py +459 -0
  6. aiohomematic/caches/visibility.py +774 -0
  7. aiohomematic/central/__init__.py +2034 -0
  8. aiohomematic/central/decorators.py +110 -0
  9. aiohomematic/central/xml_rpc_server.py +267 -0
  10. aiohomematic/client/__init__.py +1746 -0
  11. aiohomematic/client/json_rpc.py +1193 -0
  12. aiohomematic/client/xml_rpc.py +222 -0
  13. aiohomematic/const.py +795 -0
  14. aiohomematic/context.py +8 -0
  15. aiohomematic/converter.py +82 -0
  16. aiohomematic/decorators.py +188 -0
  17. aiohomematic/exceptions.py +145 -0
  18. aiohomematic/hmcli.py +159 -0
  19. aiohomematic/model/__init__.py +137 -0
  20. aiohomematic/model/calculated/__init__.py +65 -0
  21. aiohomematic/model/calculated/climate.py +230 -0
  22. aiohomematic/model/calculated/data_point.py +319 -0
  23. aiohomematic/model/calculated/operating_voltage_level.py +311 -0
  24. aiohomematic/model/calculated/support.py +174 -0
  25. aiohomematic/model/custom/__init__.py +175 -0
  26. aiohomematic/model/custom/climate.py +1334 -0
  27. aiohomematic/model/custom/const.py +146 -0
  28. aiohomematic/model/custom/cover.py +741 -0
  29. aiohomematic/model/custom/data_point.py +318 -0
  30. aiohomematic/model/custom/definition.py +861 -0
  31. aiohomematic/model/custom/light.py +1092 -0
  32. aiohomematic/model/custom/lock.py +389 -0
  33. aiohomematic/model/custom/siren.py +268 -0
  34. aiohomematic/model/custom/support.py +40 -0
  35. aiohomematic/model/custom/switch.py +172 -0
  36. aiohomematic/model/custom/valve.py +112 -0
  37. aiohomematic/model/data_point.py +1109 -0
  38. aiohomematic/model/decorators.py +173 -0
  39. aiohomematic/model/device.py +1347 -0
  40. aiohomematic/model/event.py +210 -0
  41. aiohomematic/model/generic/__init__.py +211 -0
  42. aiohomematic/model/generic/action.py +32 -0
  43. aiohomematic/model/generic/binary_sensor.py +28 -0
  44. aiohomematic/model/generic/button.py +25 -0
  45. aiohomematic/model/generic/data_point.py +162 -0
  46. aiohomematic/model/generic/number.py +73 -0
  47. aiohomematic/model/generic/select.py +36 -0
  48. aiohomematic/model/generic/sensor.py +72 -0
  49. aiohomematic/model/generic/switch.py +52 -0
  50. aiohomematic/model/generic/text.py +27 -0
  51. aiohomematic/model/hub/__init__.py +334 -0
  52. aiohomematic/model/hub/binary_sensor.py +22 -0
  53. aiohomematic/model/hub/button.py +26 -0
  54. aiohomematic/model/hub/data_point.py +332 -0
  55. aiohomematic/model/hub/number.py +37 -0
  56. aiohomematic/model/hub/select.py +47 -0
  57. aiohomematic/model/hub/sensor.py +35 -0
  58. aiohomematic/model/hub/switch.py +42 -0
  59. aiohomematic/model/hub/text.py +28 -0
  60. aiohomematic/model/support.py +599 -0
  61. aiohomematic/model/update.py +136 -0
  62. aiohomematic/py.typed +0 -0
  63. aiohomematic/rega_scripts/fetch_all_device_data.fn +75 -0
  64. aiohomematic/rega_scripts/get_program_descriptions.fn +30 -0
  65. aiohomematic/rega_scripts/get_serial.fn +44 -0
  66. aiohomematic/rega_scripts/get_system_variable_descriptions.fn +30 -0
  67. aiohomematic/rega_scripts/set_program_state.fn +12 -0
  68. aiohomematic/rega_scripts/set_system_variable.fn +15 -0
  69. aiohomematic/support.py +482 -0
  70. aiohomematic/validator.py +65 -0
  71. aiohomematic-2025.8.6.dist-info/METADATA +69 -0
  72. aiohomematic-2025.8.6.dist-info/RECORD +77 -0
  73. aiohomematic-2025.8.6.dist-info/WHEEL +5 -0
  74. aiohomematic-2025.8.6.dist-info/licenses/LICENSE +21 -0
  75. aiohomematic-2025.8.6.dist-info/top_level.txt +2 -0
  76. aiohomematic_support/__init__.py +1 -0
  77. aiohomematic_support/client_local.py +349 -0
@@ -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).