aiohomematic 2025.10.3__tar.gz → 2025.10.5__tar.gz

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 (113) hide show
  1. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/PKG-INFO +1 -1
  2. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/async_support.py +58 -22
  3. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/caches/dynamic.py +27 -14
  4. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/caches/persistent.py +12 -2
  5. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/central/__init__.py +172 -45
  6. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/client/__init__.py +23 -0
  7. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/client/json_rpc.py +10 -1
  8. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/const.py +29 -17
  9. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/decorators.py +33 -27
  10. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/property_decorators.py +38 -13
  11. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/support.py +83 -20
  12. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic.egg-info/PKG-INFO +1 -1
  13. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic.egg-info/SOURCES.txt +7 -1
  14. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic_support/client_local.py +1 -1
  15. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/pyproject.toml +10 -0
  16. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/tests/test_action.py +3 -2
  17. aiohomematic-2025.10.5/tests/test_async_support.py +171 -0
  18. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/tests/test_binary_sensor.py +4 -2
  19. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/tests/test_button.py +5 -3
  20. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/tests/test_central.py +36 -17
  21. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/tests/test_central_pydevccu.py +1 -0
  22. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/tests/test_climate.py +8 -4
  23. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/tests/test_cover.py +18 -9
  24. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/tests/test_device.py +6 -3
  25. aiohomematic-2025.10.5/tests/test_dynamic_caches.py +150 -0
  26. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/tests/test_entity.py +10 -5
  27. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/tests/test_event.py +6 -3
  28. aiohomematic-2025.10.5/tests/test_json_rpc_client_integration.py +34 -0
  29. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/tests/test_light.py +14 -7
  30. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/tests/test_lock.py +4 -2
  31. aiohomematic-2025.10.5/tests/test_logging_support.py +108 -0
  32. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/tests/test_number.py +8 -4
  33. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/tests/test_select.py +4 -2
  34. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/tests/test_sensor.py +6 -3
  35. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/tests/test_siren.py +4 -2
  36. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/tests/test_support.py +11 -6
  37. aiohomematic-2025.10.5/tests/test_support_extra.py +88 -0
  38. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/tests/test_switch.py +7 -4
  39. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/tests/test_text.py +5 -3
  40. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/tests/test_valve.py +3 -2
  41. aiohomematic-2025.10.5/tests/test_xml_rpc_proxy_integration.py +33 -0
  42. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/LICENSE +0 -0
  43. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/README.md +0 -0
  44. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/__init__.py +0 -0
  45. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/caches/__init__.py +0 -0
  46. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/caches/visibility.py +0 -0
  47. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/central/decorators.py +0 -0
  48. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/central/xml_rpc_server.py +0 -0
  49. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/client/_rpc_errors.py +0 -0
  50. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/client/xml_rpc.py +0 -0
  51. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/context.py +0 -0
  52. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/converter.py +0 -0
  53. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/exceptions.py +0 -0
  54. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/hmcli.py +0 -0
  55. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/__init__.py +0 -0
  56. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/calculated/__init__.py +0 -0
  57. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/calculated/climate.py +0 -0
  58. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/calculated/data_point.py +0 -0
  59. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/calculated/operating_voltage_level.py +0 -0
  60. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/calculated/support.py +0 -0
  61. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/custom/__init__.py +0 -0
  62. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/custom/climate.py +0 -0
  63. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/custom/const.py +0 -0
  64. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/custom/cover.py +0 -0
  65. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/custom/data_point.py +0 -0
  66. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/custom/definition.py +0 -0
  67. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/custom/light.py +0 -0
  68. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/custom/lock.py +0 -0
  69. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/custom/siren.py +0 -0
  70. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/custom/support.py +0 -0
  71. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/custom/switch.py +0 -0
  72. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/custom/valve.py +0 -0
  73. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/data_point.py +0 -0
  74. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/device.py +0 -0
  75. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/event.py +0 -0
  76. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/generic/__init__.py +0 -0
  77. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/generic/action.py +0 -0
  78. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/generic/binary_sensor.py +0 -0
  79. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/generic/button.py +0 -0
  80. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/generic/data_point.py +0 -0
  81. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/generic/number.py +0 -0
  82. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/generic/select.py +0 -0
  83. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/generic/sensor.py +0 -0
  84. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/generic/switch.py +0 -0
  85. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/generic/text.py +0 -0
  86. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/hub/__init__.py +0 -0
  87. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/hub/binary_sensor.py +0 -0
  88. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/hub/button.py +0 -0
  89. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/hub/data_point.py +0 -0
  90. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/hub/number.py +0 -0
  91. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/hub/select.py +0 -0
  92. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/hub/sensor.py +0 -0
  93. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/hub/switch.py +0 -0
  94. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/hub/text.py +0 -0
  95. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/support.py +0 -0
  96. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/model/update.py +0 -0
  97. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/py.typed +0 -0
  98. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/rega_scripts/fetch_all_device_data.fn +0 -0
  99. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/rega_scripts/get_program_descriptions.fn +0 -0
  100. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/rega_scripts/get_serial.fn +0 -0
  101. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/rega_scripts/get_system_variable_descriptions.fn +0 -0
  102. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/rega_scripts/set_program_state.fn +0 -0
  103. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/rega_scripts/set_system_variable.fn +0 -0
  104. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic/validator.py +0 -0
  105. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic.egg-info/dependency_links.txt +0 -0
  106. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic.egg-info/requires.txt +0 -0
  107. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic.egg-info/top_level.txt +0 -0
  108. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/aiohomematic_support/__init__.py +0 -0
  109. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/setup.cfg +0 -0
  110. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/tests/test_calculated_support.py +0 -0
  111. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/tests/test_decorator.py +0 -0
  112. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/tests/test_json_rpc.py +0 -0
  113. {aiohomematic-2025.10.3 → aiohomematic-2025.10.5}/tests/test_kwonly_lint.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiohomematic
3
- Version: 2025.10.3
3
+ Version: 2025.10.5
4
4
  Summary: Homematic interface for Home Assistant running on Python 3.
5
5
  Home-page: https://github.com/sukramj/aiohomematic
6
6
  Author-email: SukramJ <sukramj@icloud.com>, Daniel Perna <danielperna84@gmail.com>
@@ -15,7 +15,8 @@ from typing import Any, Final, cast
15
15
 
16
16
  from aiohomematic.const import BLOCK_LOG_TIMEOUT
17
17
  from aiohomematic.exceptions import AioHomematicException
18
- from aiohomematic.support import debug_enabled, extract_exc_args
18
+ import aiohomematic.support as hms
19
+ from aiohomematic.support import extract_exc_args
19
20
 
20
21
  _LOGGER: Final = logging.getLogger(__name__)
21
22
 
@@ -46,7 +47,13 @@ class Looper:
46
47
  _LOGGER.warning("Shutdown timeout reached; task still pending: %s", task)
47
48
  break
48
49
 
49
- await self._await_and_log_pending(pending=tasks)
50
+ pending_after_wait = await self._await_and_log_pending(pending=tasks, deadline=deadline)
51
+
52
+ # If deadline has been reached and tasks are still pending, log and break
53
+ if deadline is not None and monotonic() >= deadline and pending_after_wait:
54
+ for task in pending_after_wait:
55
+ _LOGGER.warning("Shutdown timeout reached; task still pending: %s", task)
56
+ break
50
57
 
51
58
  if start_time is None:
52
59
  # Avoid calling monotonic() until we know
@@ -63,16 +70,35 @@ class Looper:
63
70
  for task in tasks:
64
71
  _LOGGER.debug("Waiting for task: %s", task)
65
72
 
66
- async def _await_and_log_pending(self, *, pending: Collection[asyncio.Future[Any]]) -> None:
67
- """Await and log tasks that take a long time."""
68
- wait_time = 0
69
- while pending:
70
- _, pending = await asyncio.wait(pending, timeout=BLOCK_LOG_TIMEOUT)
71
- if not pending:
72
- return
73
- wait_time += BLOCK_LOG_TIMEOUT
74
- for task in pending:
73
+ async def _await_and_log_pending(
74
+ self, *, pending: Collection[asyncio.Future[Any]], deadline: float | None
75
+ ) -> set[asyncio.Future[Any]]:
76
+ """
77
+ Await and log tasks that take a long time, respecting an optional deadline.
78
+
79
+ Returns the set of pending tasks if the deadline has been reached (or zero timeout),
80
+ allowing the caller to decide about timeout logging. Returns an empty set if no tasks are pending.
81
+ """
82
+ wait_time = 0.0
83
+ pending_set: set[asyncio.Future[Any]] = set(pending)
84
+ while pending_set:
85
+ if deadline is None:
86
+ timeout = BLOCK_LOG_TIMEOUT
87
+ else:
88
+ remaining = int(max(0.0, deadline - monotonic()))
89
+ if (timeout := min(BLOCK_LOG_TIMEOUT, remaining)) == 0.0:
90
+ # Deadline reached; return current pending to caller for warning log
91
+ return pending_set
92
+ done, still_pending = await asyncio.wait(pending_set, timeout=timeout)
93
+ if not (pending_set := set(still_pending)):
94
+ return set()
95
+ wait_time += timeout
96
+ for task in pending_set:
75
97
  _LOGGER.debug("Waited %s seconds for task: %s", wait_time, task)
98
+ # If the deadline was reached during the wait, let caller handle warning
99
+ if deadline is not None and monotonic() >= deadline:
100
+ return pending_set
101
+ return set()
76
102
 
77
103
  def create_task(self, *, target: Coroutine[Any, Any, Any], name: str) -> None:
78
104
  """Add task to the executor pool."""
@@ -134,7 +160,12 @@ def cancelling(*, task: asyncio.Future[Any]) -> bool:
134
160
 
135
161
 
136
162
  def loop_check[**P, R](func: Callable[P, R]) -> Callable[P, R]:
137
- """Annotation to mark method that must be run within the event loop."""
163
+ """
164
+ Annotation to mark method that must be run within the event loop.
165
+
166
+ Always wraps the function, but only performs loop checks when debug is enabled.
167
+ This allows tests to monkeypatch aiohomematic.support.debug_enabled at runtime.
168
+ """
138
169
 
139
170
  _with_loop: set = set()
140
171
 
@@ -143,17 +174,22 @@ def loop_check[**P, R](func: Callable[P, R]) -> Callable[P, R]:
143
174
  """Wrap loop check."""
144
175
  return_value = func(*args, **kwargs)
145
176
 
146
- try:
147
- asyncio.get_running_loop()
148
- loop_running = True
149
- except Exception:
150
- loop_running = False
151
-
152
- if not loop_running and func not in _with_loop:
153
- _with_loop.add(func)
154
- _LOGGER.warning("Method %s must run in the event_loop. No loop detected.", func.__name__)
177
+ # Only perform the (potentially expensive) loop check when debug is enabled.
178
+ if hms.debug_enabled():
179
+ try:
180
+ asyncio.get_running_loop()
181
+ loop_running = True
182
+ except Exception:
183
+ loop_running = False
184
+
185
+ if not loop_running and func not in _with_loop:
186
+ _with_loop.add(func)
187
+ _LOGGER.warning(
188
+ "Method %s must run in the event_loop. No loop detected.",
189
+ func.__name__,
190
+ )
155
191
 
156
192
  return return_value
157
193
 
158
194
  setattr(func, "_loop_check", True)
159
- return cast(Callable[P, R], wrapper_loop_check) if debug_enabled() else func
195
+ return cast(Callable[P, R], wrapper_loop_check)
@@ -400,15 +400,15 @@ class PingPongCache:
400
400
 
401
401
  @property
402
402
  def low_pending_pongs(self) -> bool:
403
- """Return the pending pong count is low."""
403
+ """Return True when pending pong count is at or below the allowed delta (i.e., not high)."""
404
404
  self._cleanup_pending_pongs()
405
- return len(self._pending_pongs) < (self._allowed_delta / 2)
405
+ return len(self._pending_pongs) <= self._allowed_delta
406
406
 
407
407
  @property
408
408
  def low_unknown_pongs(self) -> bool:
409
- """Return the unknown pong count is low."""
409
+ """Return True when unknown pong count is at or below the allowed delta (i.e., not high)."""
410
410
  self._cleanup_unknown_pongs()
411
- return len(self._unknown_pongs) < (self._allowed_delta / 2)
411
+ return len(self._unknown_pongs) <= self._allowed_delta
412
412
 
413
413
  @property
414
414
  def pending_pong_count(self) -> int:
@@ -430,10 +430,14 @@ class PingPongCache:
430
430
  def handle_send_ping(self, *, ping_ts: datetime) -> None:
431
431
  """Handle send ping timestamp."""
432
432
  self._pending_pongs.add(ping_ts)
433
- self._check_and_fire_pong_event(
434
- event_type=InterfaceEventType.PENDING_PONG,
435
- pong_mismatch_count=self.pending_pong_count,
436
- )
433
+ # Throttle event emission to every second ping to avoid spamming callbacks,
434
+ # but always emit when crossing the high threshold.
435
+ count = self.pending_pong_count
436
+ if (count > self._allowed_delta) or (count % 2 == 0):
437
+ self._check_and_fire_pong_event(
438
+ event_type=InterfaceEventType.PENDING_PONG,
439
+ pong_mismatch_count=count,
440
+ )
437
441
  _LOGGER.debug(
438
442
  "PING PONG CACHE: Increase pending PING count: %s - %i for ts: %s",
439
443
  self._interface_id,
@@ -473,8 +477,8 @@ class PingPongCache:
473
477
  """Cleanup too old pending pongs."""
474
478
  dt_now = datetime.now()
475
479
  for pong_ts in list(self._pending_pongs):
476
- delta = dt_now - pong_ts
477
- if delta.seconds > self._ttl:
480
+ # Only expire entries that are actually older than the TTL.
481
+ if (dt_now - pong_ts).total_seconds() > self._ttl:
478
482
  self._pending_pongs.remove(pong_ts)
479
483
  _LOGGER.debug(
480
484
  "PING PONG CACHE: Removing expired pending PONG: %s - %i for ts: %s",
@@ -487,8 +491,8 @@ class PingPongCache:
487
491
  """Cleanup too old unknown pongs."""
488
492
  dt_now = datetime.now()
489
493
  for pong_ts in list(self._unknown_pongs):
490
- delta = dt_now - pong_ts
491
- if delta.seconds > self._ttl:
494
+ # Only expire entries that are actually older than the TTL.
495
+ if (dt_now - pong_ts).total_seconds() > self._ttl:
492
496
  self._unknown_pongs.remove(pong_ts)
493
497
  _LOGGER.debug(
494
498
  "PING PONG CACHE: Removing expired unknown PONG: %s - %i or ts: %s",
@@ -519,11 +523,20 @@ class PingPongCache:
519
523
  )
520
524
 
521
525
  if self.low_pending_pongs and event_type == InterfaceEventType.PENDING_PONG:
522
- _fire_event(mismatch_count=0)
523
- self._pending_pong_logged = False
526
+ # In low state:
527
+ # - If we previously logged a high state, emit a reset event (mismatch=0) exactly once.
528
+ # - Otherwise, throttle emission to every second ping (even counts > 0) to avoid spamming.
529
+ if self._pending_pong_logged:
530
+ _fire_event(mismatch_count=0)
531
+ self._pending_pong_logged = False
532
+ return
533
+ if pong_mismatch_count > 0 and pong_mismatch_count % 2 == 0:
534
+ _fire_event(mismatch_count=pong_mismatch_count)
524
535
  return
525
536
 
526
537
  if self.low_unknown_pongs and event_type == InterfaceEventType.UNKNOWN_PONG:
538
+ # For unknown pongs, only reset the logged flag when we drop below the threshold.
539
+ # We do not emit an event here since there is no explicit expectation for a reset notification.
527
540
  self._unknown_pong_logged = False
528
541
  return
529
542
 
@@ -250,14 +250,24 @@ class DeviceDescriptionCache(BasePersistentCache):
250
250
  addr_map.pop(address, None)
251
251
  desc_map.pop(address, None)
252
252
 
253
- def get_addresses(self, *, interface_id: str) -> frozenset[str]:
253
+ def get_addresses(self, *, interface_id: str | None = None) -> frozenset[str]:
254
254
  """Return the addresses by interface as a set."""
255
- return frozenset(self._addresses[interface_id])
255
+ if interface_id:
256
+ return frozenset(self._addresses[interface_id])
257
+ return frozenset(addr for interface_id in self.get_interface_ids() for addr in self._addresses[interface_id])
256
258
 
257
259
  def get_device_descriptions(self, *, interface_id: str) -> Mapping[str, DeviceDescription]:
258
260
  """Return the devices by interface."""
259
261
  return self._device_descriptions[interface_id]
260
262
 
263
+ def get_interface_ids(self) -> tuple[str, ...]:
264
+ """Return the interface ids."""
265
+ return tuple(self._raw_device_descriptions.keys())
266
+
267
+ def has_device_descriptions(self, *, interface_id: str) -> bool:
268
+ """Return the devices by interface."""
269
+ return interface_id in self._device_descriptions
270
+
261
271
  def find_device_description(self, *, interface_id: str, device_address: str) -> DeviceDescription | None:
262
272
  """Return the device description by interface and device_address."""
263
273
  return self._device_descriptions[interface_id].get(device_address)
@@ -92,6 +92,7 @@ from aiohomematic.const import (
92
92
  CONNECTION_CHECKER_INTERVAL,
93
93
  DATA_POINT_EVENTS,
94
94
  DATETIME_FORMAT_MILLIS,
95
+ DEFAULT_DELAY_NEW_DEVICE_CREATION,
95
96
  DEFAULT_ENABLE_DEVICE_FIRMWARE_CHECK,
96
97
  DEFAULT_ENABLE_PROGRAM_SCAN,
97
98
  DEFAULT_ENABLE_SYSVAR_SCAN,
@@ -136,6 +137,7 @@ from aiohomematic.const import (
136
137
  Parameter,
137
138
  ParamsetKey,
138
139
  ProxyInitState,
140
+ SourceOfDeviceCreation,
139
141
  SystemInformation,
140
142
  )
141
143
  from aiohomematic.decorators import inspector
@@ -164,6 +166,7 @@ from aiohomematic.support import (
164
166
  LogContextMixin,
165
167
  PayloadMixin,
166
168
  check_config,
169
+ extract_device_addresses_from_device_descriptions,
167
170
  extract_exc_args,
168
171
  get_channel_no,
169
172
  get_device_address,
@@ -506,7 +509,9 @@ class CentralUnit(LogContextMixin, PayloadMixin):
506
509
  if self._config.start_direct:
507
510
  if await self._create_clients():
508
511
  for client in self._clients.values():
509
- await self._refresh_device_descriptions(client=client)
512
+ await self._refresh_device_descriptions_and_create_missing_devices(
513
+ client=client, refresh_only_existing=False
514
+ )
510
515
  else:
511
516
  self._clients_started = await self._start_clients()
512
517
  if self._config.enable_server:
@@ -576,11 +581,15 @@ class CentralUnit(LogContextMixin, PayloadMixin):
576
581
  async def refresh_firmware_data(self, *, device_address: str | None = None) -> None:
577
582
  """Refresh device firmware data."""
578
583
  if device_address and (device := self.get_device(address=device_address)) is not None and device.is_updatable:
579
- await self._refresh_device_descriptions(client=device.client, device_address=device_address)
584
+ await self._refresh_device_descriptions_and_create_missing_devices(
585
+ client=device.client, refresh_only_existing=True, device_address=device_address
586
+ )
580
587
  device.refresh_firmware_data()
581
588
  else:
582
589
  for client in self._clients.values():
583
- await self._refresh_device_descriptions(client=client)
590
+ await self._refresh_device_descriptions_and_create_missing_devices(
591
+ client=client, refresh_only_existing=True
592
+ )
584
593
  for device in self._devices.values():
585
594
  if device.is_updatable:
586
595
  device.refresh_firmware_data()
@@ -595,9 +604,12 @@ class CentralUnit(LogContextMixin, PayloadMixin):
595
604
  ]:
596
605
  await self.refresh_firmware_data(device_address=device.address)
597
606
 
598
- async def _refresh_device_descriptions(self, *, client: hmcl.Client, device_address: str | None = None) -> None:
599
- """Refresh device descriptions."""
607
+ async def _refresh_device_descriptions_and_create_missing_devices(
608
+ self, *, client: hmcl.Client, refresh_only_existing: bool, device_address: str | None = None
609
+ ) -> None:
610
+ """Refresh device descriptions and create missing devices."""
600
611
  device_descriptions: tuple[DeviceDescription, ...] | None = None
612
+
601
613
  if (
602
614
  device_address
603
615
  and (device_description := await client.get_device_description(device_address=device_address)) is not None
@@ -606,10 +618,25 @@ class CentralUnit(LogContextMixin, PayloadMixin):
606
618
  else:
607
619
  device_descriptions = await client.list_devices()
608
620
 
621
+ if (
622
+ device_descriptions
623
+ and refresh_only_existing
624
+ and (
625
+ existing_device_descriptions := tuple(
626
+ dev_desc
627
+ for dev_desc in list(device_descriptions)
628
+ if dev_desc["ADDRESS"]
629
+ in self.device_descriptions.get_device_descriptions(interface_id=client.interface_id)
630
+ )
631
+ )
632
+ ):
633
+ device_descriptions = existing_device_descriptions
634
+
609
635
  if device_descriptions:
610
636
  await self._add_new_devices(
611
637
  interface_id=client.interface_id,
612
638
  device_descriptions=device_descriptions,
639
+ source=SourceOfDeviceCreation.REFRESH,
613
640
  )
614
641
 
615
642
  async def _start_clients(self) -> bool:
@@ -618,13 +645,15 @@ class CentralUnit(LogContextMixin, PayloadMixin):
618
645
  return False
619
646
  await self._load_caches()
620
647
  if new_device_addresses := self._check_for_new_device_addresses():
621
- await self._create_devices(new_device_addresses=new_device_addresses)
648
+ await self._create_devices(new_device_addresses=new_device_addresses, source=SourceOfDeviceCreation.CACHE)
622
649
  await self._init_hub()
623
650
  await self._init_clients()
624
651
  # Proactively fetch device descriptions if none were created yet to avoid slow startup
625
652
  if not self._devices:
626
653
  for client in self._clients.values():
627
- await self._refresh_device_descriptions(client=client)
654
+ await self._refresh_device_descriptions_and_create_missing_devices(
655
+ client=client, refresh_only_existing=False
656
+ )
628
657
  return True
629
658
 
630
659
  async def _stop_clients(self) -> None:
@@ -934,7 +963,9 @@ class CentralUnit(LogContextMixin, PayloadMixin):
934
963
  await self._data_cache.load()
935
964
  return True
936
965
 
937
- async def _create_devices(self, *, new_device_addresses: Mapping[str, set[str]]) -> None:
966
+ async def _create_devices(
967
+ self, *, new_device_addresses: Mapping[str, set[str]], source: SourceOfDeviceCreation
968
+ ) -> None:
938
969
  """Trigger creation of the objects that expose the functionality."""
939
970
  if not self._clients:
940
971
  raise AioHomematicException(
@@ -988,6 +1019,7 @@ class CentralUnit(LogContextMixin, PayloadMixin):
988
1019
  system_event=BackendSystemEvent.DEVICES_CREATED,
989
1020
  new_data_points=new_dps,
990
1021
  new_channel_events=new_channel_events,
1022
+ source=source,
991
1023
  )
992
1024
 
993
1025
  async def delete_device(self, *, interface_id: str, device_address: str) -> None:
@@ -1014,15 +1046,45 @@ class CentralUnit(LogContextMixin, PayloadMixin):
1014
1046
  for address in addresses:
1015
1047
  if device := self._devices.get(address):
1016
1048
  self.remove_device(device=device)
1017
- await self.save_caches()
1049
+ await self.save_caches(save_device_descriptions=True, save_paramset_descriptions=True)
1018
1050
 
1019
1051
  @callback_backend_system(system_event=BackendSystemEvent.NEW_DEVICES)
1020
1052
  async def add_new_devices(self, *, interface_id: str, device_descriptions: tuple[DeviceDescription, ...]) -> None:
1021
1053
  """Add new devices to central unit."""
1022
- await self._add_new_devices(interface_id=interface_id, device_descriptions=device_descriptions)
1054
+ source = (
1055
+ SourceOfDeviceCreation.NEW
1056
+ if self._device_descriptions.has_device_descriptions(interface_id=interface_id)
1057
+ else SourceOfDeviceCreation.INIT
1058
+ )
1059
+ await self._add_new_devices(interface_id=interface_id, device_descriptions=device_descriptions, source=source)
1060
+
1061
+ async def add_new_device_manually(self, *, interface_id: str, address: str) -> None:
1062
+ """Add new devices manually triggered to central unit."""
1063
+ if interface_id not in self._clients:
1064
+ _LOGGER.warning(
1065
+ "ADD_NEW_DEVICES_MANUALLY failed: Missing client for interface_id %s",
1066
+ interface_id,
1067
+ )
1068
+ return
1069
+ client = self._clients[interface_id]
1070
+ if (device_descriptions := await client.get_all_device_description(device_address=address)) is None:
1071
+ _LOGGER.warning(
1072
+ "ADD_NEW_DEVICES_MANUALLY failed: No device description found for address %s on interface_id %s",
1073
+ address,
1074
+ interface_id,
1075
+ )
1076
+ return
1077
+
1078
+ await self._add_new_devices(
1079
+ interface_id=interface_id,
1080
+ device_descriptions=device_descriptions,
1081
+ source=SourceOfDeviceCreation.MANUAL,
1082
+ )
1023
1083
 
1024
1084
  @inspector(measure_performance=True)
1025
- async def _add_new_devices(self, *, interface_id: str, device_descriptions: tuple[DeviceDescription, ...]) -> None:
1085
+ async def _add_new_devices(
1086
+ self, *, interface_id: str, device_descriptions: tuple[DeviceDescription, ...], source: SourceOfDeviceCreation
1087
+ ) -> None:
1026
1088
  """Add new devices to central unit."""
1027
1089
  if not device_descriptions:
1028
1090
  _LOGGER.debug(
@@ -1045,57 +1107,96 @@ class CentralUnit(LogContextMixin, PayloadMixin):
1045
1107
  return
1046
1108
 
1047
1109
  async with self._device_add_semaphore:
1048
- # Use mapping membership to avoid rebuilding known addresses and allow O(1) checks.
1049
- existing_map = self._device_descriptions.get_device_descriptions(interface_id=interface_id)
1110
+ if not (
1111
+ new_device_descriptions := self._identify_new_device_descriptions(
1112
+ device_descriptions=device_descriptions, interface_id=interface_id
1113
+ )
1114
+ ):
1115
+ _LOGGER.debug("ADD_NEW_DEVICES: Nothing to add for interface_id %s", interface_id)
1116
+ return
1117
+
1118
+ # Here we block the automatic creation of new devices, if required
1119
+ if (
1120
+ self._config.delay_new_device_creation
1121
+ and source == SourceOfDeviceCreation.NEW
1122
+ and (
1123
+ new_addresses := extract_device_addresses_from_device_descriptions(
1124
+ device_descriptions=new_device_descriptions
1125
+ )
1126
+ )
1127
+ ):
1128
+ self.fire_backend_system_callback(
1129
+ system_event=BackendSystemEvent.DEVICES_DELAYED,
1130
+ new_addresses=new_addresses,
1131
+ interface_id=interface_id,
1132
+ source=source,
1133
+ )
1134
+ return
1135
+
1050
1136
  client = self._clients[interface_id]
1051
- save_paramset_descriptions = False
1052
- save_device_descriptions = False
1053
- for dev_desc in device_descriptions:
1137
+ save_descriptions = False
1138
+ for dev_desc in new_device_descriptions:
1054
1139
  try:
1055
- address = dev_desc["ADDRESS"]
1056
- # Check existence before mutating cache to ensure we detect truly new addresses.
1057
- is_new_address = address not in existing_map
1058
1140
  self._device_descriptions.add_device(interface_id=interface_id, device_description=dev_desc)
1059
- save_device_descriptions = True
1060
- if is_new_address:
1061
- await client.fetch_paramset_descriptions(device_description=dev_desc)
1062
- save_paramset_descriptions = True
1141
+ await client.fetch_paramset_descriptions(device_description=dev_desc)
1142
+ save_descriptions = True
1063
1143
  except Exception as exc: # pragma: no cover
1064
- save_device_descriptions = False
1065
- save_paramset_descriptions = False
1144
+ save_descriptions = False
1066
1145
  _LOGGER.error(
1067
- "ADD_NEW_DEVICES failed: %s [%s]",
1146
+ "UPDATE_CACHES_WITH_NEW_DEVICES failed: %s [%s]",
1068
1147
  type(exc).__name__,
1069
1148
  extract_exc_args(exc=exc),
1070
1149
  )
1071
1150
 
1072
1151
  await self.save_caches(
1073
- save_device_descriptions=save_device_descriptions,
1074
- save_paramset_descriptions=save_paramset_descriptions,
1152
+ save_device_descriptions=save_descriptions,
1153
+ save_paramset_descriptions=save_descriptions,
1075
1154
  )
1076
- if new_device_addresses := self._check_for_new_device_addresses():
1077
- await self._device_details.load()
1078
- await self._data_cache.load()
1079
- await self._create_devices(new_device_addresses=new_device_addresses)
1080
1155
 
1081
- def _check_for_new_device_addresses(self) -> Mapping[str, set[str]]:
1156
+ if new_device_addresses := self._check_for_new_device_addresses(interface_id=interface_id):
1157
+ await self._device_details.load()
1158
+ await self._data_cache.load(interface=client.interface)
1159
+ await self._create_devices(new_device_addresses=new_device_addresses, source=source)
1160
+
1161
+ def _identify_new_device_descriptions(
1162
+ self, *, device_descriptions: tuple[DeviceDescription, ...], interface_id: str | None = None
1163
+ ) -> tuple[DeviceDescription, ...]:
1164
+ """Identify devices whose ADDRESS isn't already known on any interface."""
1165
+ known_addresses = self._device_descriptions.get_addresses(interface_id=interface_id)
1166
+ return tuple(
1167
+ dev_desc
1168
+ for dev_desc in device_descriptions
1169
+ if (dev_desc["ADDRESS"] if not (parent_address := dev_desc.get("PARENT")) else parent_address)
1170
+ not in known_addresses
1171
+ )
1172
+
1173
+ def _check_for_new_device_addresses(self, *, interface_id: str | None = None) -> Mapping[str, set[str]]:
1082
1174
  """Check if there are new devices that need to be created."""
1083
1175
  new_device_addresses: dict[str, set[str]] = {}
1084
- for interface_id in self.interface_ids:
1085
- if not self._paramset_descriptions.has_interface_id(interface_id=interface_id):
1176
+
1177
+ # Cache existing device addresses once to avoid repeated mapping lookups
1178
+ existing_addresses = set(self._devices.keys())
1179
+
1180
+ def _check_for_new_device_addresses_helper(*, iid: str) -> None:
1181
+ """Check if there are new devices that need to be created."""
1182
+ if not self._paramset_descriptions.has_interface_id(interface_id=iid):
1086
1183
  _LOGGER.debug(
1087
1184
  "CHECK_FOR_NEW_DEVICE_ADDRESSES: Skipping interface %s, missing paramsets",
1088
- interface_id,
1185
+ iid,
1089
1186
  )
1090
- continue
1091
-
1187
+ return
1092
1188
  # Build the set locally and assign only if non-empty to avoid add-then-delete pattern
1093
- new_set: set[str] = set()
1094
- for device_address in self._device_descriptions.get_addresses(interface_id=interface_id):
1095
- if device_address not in self._devices:
1096
- new_set.add(device_address)
1097
- if new_set:
1098
- new_device_addresses[interface_id] = new_set
1189
+ # Use set difference for speed on large collections
1190
+ addresses = set(self._device_descriptions.get_addresses(interface_id=iid))
1191
+ # get_addresses returns an iterable (likely tuple); convert to set once for efficient diff
1192
+ if new_set := addresses - existing_addresses:
1193
+ new_device_addresses[iid] = new_set
1194
+
1195
+ if interface_id:
1196
+ _check_for_new_device_addresses_helper(iid=interface_id)
1197
+ else:
1198
+ for iid in self.interface_ids:
1199
+ _check_for_new_device_addresses_helper(iid=iid)
1099
1200
 
1100
1201
  if _LOGGER.isEnabledFor(level=DEBUG):
1101
1202
  count = sum(len(item) for item in new_device_addresses.values())
@@ -1670,12 +1771,31 @@ class _Scheduler(threading.Thread):
1670
1771
  _LOGGER.debug("SCHEDULER: Waiting till central %s is started", self._central.name)
1671
1772
  await asyncio.sleep(SCHEDULER_NOT_STARTED_SLEEP)
1672
1773
  continue
1774
+
1775
+ any_executed = False
1673
1776
  for job in self._scheduler_jobs:
1674
1777
  if not self._active or not job.ready:
1675
1778
  continue
1676
1779
  await job.run()
1677
1780
  job.schedule_next_execution()
1678
- if self._active:
1781
+ any_executed = True
1782
+
1783
+ if not self._active:
1784
+ break # type: ignore[unreachable]
1785
+
1786
+ # If no job was executed this cycle, we can sleep until the next job is due
1787
+ if not any_executed:
1788
+ now = datetime.now()
1789
+ try:
1790
+ next_due = min(job.next_run for job in self._scheduler_jobs)
1791
+ # Sleep until the next task should run, but cap to 1s to remain responsive
1792
+ delay = max(0.0, (next_due - now).total_seconds())
1793
+ await asyncio.sleep(min(1.0, delay))
1794
+ except ValueError:
1795
+ # No jobs configured; fallback to default loop sleep
1796
+ await asyncio.sleep(SCHEDULER_LOOP_SLEEP)
1797
+ else:
1798
+ # When work was done, yield briefly to the loop
1679
1799
  await asyncio.sleep(SCHEDULER_LOOP_SLEEP)
1680
1800
 
1681
1801
  async def _check_connection(self) -> None:
@@ -1823,6 +1943,11 @@ class _SchedulerJob:
1823
1943
  """Return if the job can be executed."""
1824
1944
  return self._next_run < datetime.now()
1825
1945
 
1946
+ @property
1947
+ def next_run(self) -> datetime:
1948
+ """Return the next scheduled run timestamp."""
1949
+ return self._next_run
1950
+
1826
1951
  async def run(self) -> None:
1827
1952
  """Run the task."""
1828
1953
  await self._task()
@@ -1848,6 +1973,7 @@ class CentralConfig:
1848
1973
  callback_host: str | None = None,
1849
1974
  callback_port: int | None = None,
1850
1975
  default_callback_port: int = PORT_ANY,
1976
+ delay_new_device_creation: bool = DEFAULT_DELAY_NEW_DEVICE_CREATION,
1851
1977
  enable_device_firmware_check: bool = DEFAULT_ENABLE_DEVICE_FIRMWARE_CHECK,
1852
1978
  enable_program_scan: bool = DEFAULT_ENABLE_PROGRAM_SCAN,
1853
1979
  enable_sysvar_scan: bool = DEFAULT_ENABLE_SYSVAR_SCAN,
@@ -1876,6 +2002,7 @@ class CentralConfig:
1876
2002
  self.central_id: Final = central_id
1877
2003
  self.client_session: Final = client_session
1878
2004
  self.default_callback_port: Final = default_callback_port
2005
+ self.delay_new_device_creation: Final = delay_new_device_creation
1879
2006
  self.enable_device_firmware_check: Final = enable_device_firmware_check
1880
2007
  self.enable_program_scan: Final = enable_program_scan
1881
2008
  self.enable_sysvar_scan: Final = enable_sysvar_scan
@@ -494,6 +494,29 @@ class Client(ABC, LogContextMixin):
494
494
  _LOGGER.warning("GET_DEVICE_DESCRIPTIONS failed: %s [%s]", bhexc.name, extract_exc_args(exc=bhexc))
495
495
  return None
496
496
 
497
+ @inspector(re_raise=False)
498
+ async def get_all_device_description(self, *, device_address: str) -> tuple[DeviceDescription, ...] | None:
499
+ """Get all device descriptions from the backend."""
500
+ all_device_description: list[DeviceDescription] = []
501
+ if main_dd := await self.get_device_description(device_address=device_address):
502
+ all_device_description.append(main_dd)
503
+ else:
504
+ _LOGGER.warning(
505
+ "GET_ALL_DEVICE_DESCRIPTIONS: No device description for %s",
506
+ device_address,
507
+ )
508
+
509
+ if main_dd:
510
+ for channel_address in main_dd["CHILDREN"]:
511
+ if channel_dd := await self.get_device_description(device_address=channel_address):
512
+ all_device_description.append(channel_dd)
513
+ else:
514
+ _LOGGER.warning(
515
+ "GET_ALL_DEVICE_DESCRIPTIONS: No channel description for %s",
516
+ channel_address,
517
+ )
518
+ return tuple(all_device_description)
519
+
497
520
  @inspector
498
521
  async def add_link(self, *, sender_address: str, receiver_address: str, name: str, description: str) -> None:
499
522
  """Return a list of links."""
@@ -1259,7 +1259,16 @@ class JsonRpcAioHttpClient(LogContextMixin):
1259
1259
  response = await self._post_script(script_name=RegaScript.GET_SERIAL)
1260
1260
 
1261
1261
  if json_result := response[_JsonKey.RESULT]:
1262
- serial: str = json_result[_JsonKey.SERIAL]
1262
+ # The backend may return a JSON string which needs to be decoded first
1263
+ # or an already-parsed dict. Support both.
1264
+ if isinstance(json_result, str):
1265
+ try:
1266
+ json_result = orjson.loads(json_result)
1267
+ except Exception:
1268
+ # Fall back to plain string handling; return last 10 chars
1269
+ serial_exc = str(json_result)
1270
+ return serial_exc[-10:] if len(serial_exc) > 10 else serial_exc
1271
+ serial: str = str(json_result.get(_JsonKey.SERIAL) if isinstance(json_result, dict) else json_result)
1263
1272
  if len(serial) > 10:
1264
1273
  serial = serial[-10:]
1265
1274
  return serial