aiohomematic 2025.10.3__py3-none-any.whl → 2025.10.5__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/async_support.py +58 -22
- aiohomematic/caches/dynamic.py +27 -14
- aiohomematic/caches/persistent.py +12 -2
- aiohomematic/central/__init__.py +172 -45
- aiohomematic/client/__init__.py +23 -0
- aiohomematic/client/json_rpc.py +10 -1
- aiohomematic/const.py +29 -17
- aiohomematic/decorators.py +33 -27
- aiohomematic/property_decorators.py +38 -13
- aiohomematic/support.py +83 -20
- {aiohomematic-2025.10.3.dist-info → aiohomematic-2025.10.5.dist-info}/METADATA +1 -1
- {aiohomematic-2025.10.3.dist-info → aiohomematic-2025.10.5.dist-info}/RECORD +16 -16
- aiohomematic_support/client_local.py +1 -1
- {aiohomematic-2025.10.3.dist-info → aiohomematic-2025.10.5.dist-info}/WHEEL +0 -0
- {aiohomematic-2025.10.3.dist-info → aiohomematic-2025.10.5.dist-info}/licenses/LICENSE +0 -0
- {aiohomematic-2025.10.3.dist-info → aiohomematic-2025.10.5.dist-info}/top_level.txt +0 -0
aiohomematic/async_support.py
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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)
|
|
195
|
+
return cast(Callable[P, R], wrapper_loop_check)
|
aiohomematic/caches/dynamic.py
CHANGED
|
@@ -400,15 +400,15 @@ class PingPongCache:
|
|
|
400
400
|
|
|
401
401
|
@property
|
|
402
402
|
def low_pending_pongs(self) -> bool:
|
|
403
|
-
"""Return
|
|
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)
|
|
405
|
+
return len(self._pending_pongs) <= self._allowed_delta
|
|
406
406
|
|
|
407
407
|
@property
|
|
408
408
|
def low_unknown_pongs(self) -> bool:
|
|
409
|
-
"""Return
|
|
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)
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
477
|
-
if
|
|
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
|
-
|
|
491
|
-
if
|
|
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
|
-
|
|
523
|
-
|
|
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
|
-
|
|
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)
|
aiohomematic/central/__init__.py
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
|
599
|
-
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1049
|
-
|
|
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
|
-
|
|
1052
|
-
|
|
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
|
-
|
|
1060
|
-
|
|
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
|
-
|
|
1065
|
-
save_paramset_descriptions = False
|
|
1144
|
+
save_descriptions = False
|
|
1066
1145
|
_LOGGER.error(
|
|
1067
|
-
"
|
|
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=
|
|
1074
|
-
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
|
-
|
|
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
|
-
|
|
1085
|
-
|
|
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
|
-
|
|
1185
|
+
iid,
|
|
1089
1186
|
)
|
|
1090
|
-
|
|
1091
|
-
|
|
1187
|
+
return
|
|
1092
1188
|
# Build the set locally and assign only if non-empty to avoid add-then-delete pattern
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
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
|
-
|
|
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
|
aiohomematic/client/__init__.py
CHANGED
|
@@ -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."""
|
aiohomematic/client/json_rpc.py
CHANGED
|
@@ -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
|
-
|
|
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
|
aiohomematic/const.py
CHANGED
|
@@ -19,7 +19,7 @@ import sys
|
|
|
19
19
|
from types import MappingProxyType
|
|
20
20
|
from typing import Any, Final, NamedTuple, Required, TypeAlias, TypedDict
|
|
21
21
|
|
|
22
|
-
VERSION: Final = "2025.10.
|
|
22
|
+
VERSION: Final = "2025.10.5"
|
|
23
23
|
|
|
24
24
|
# Detect test speedup mode via environment
|
|
25
25
|
_TEST_SPEEDUP: Final = (
|
|
@@ -29,6 +29,7 @@ _TEST_SPEEDUP: Final = (
|
|
|
29
29
|
# default
|
|
30
30
|
DEFAULT_STORAGE_FOLDER: Final = "aiohomematic_storage"
|
|
31
31
|
DEFAULT_CUSTOM_ID: Final = "custom_id"
|
|
32
|
+
DEFAULT_DELAY_NEW_DEVICE_CREATION: Final = False
|
|
32
33
|
DEFAULT_ENABLE_DEVICE_FIRMWARE_CHECK: Final = False
|
|
33
34
|
DEFAULT_ENABLE_PROGRAM_SCAN: Final = True
|
|
34
35
|
DEFAULT_ENABLE_SYSVAR_SCAN: Final = True
|
|
@@ -124,8 +125,8 @@ UN_IGNORE_WILDCARD: Final = "all"
|
|
|
124
125
|
WAIT_FOR_CALLBACK: Final[int | None] = None
|
|
125
126
|
|
|
126
127
|
# Scheduler sleep durations (used by central scheduler loop)
|
|
127
|
-
SCHEDULER_NOT_STARTED_SLEEP: Final = 0.
|
|
128
|
-
SCHEDULER_LOOP_SLEEP: Final = 0.
|
|
128
|
+
SCHEDULER_NOT_STARTED_SLEEP: Final = 0.2 if _TEST_SPEEDUP else 10
|
|
129
|
+
SCHEDULER_LOOP_SLEEP: Final = 0.2 if _TEST_SPEEDUP else 5
|
|
129
130
|
|
|
130
131
|
CALLBACK_WARN_INTERVAL: Final = CONNECTION_CHECKER_INTERVAL * 40
|
|
131
132
|
|
|
@@ -155,6 +156,7 @@ class BackendSystemEvent(StrEnum):
|
|
|
155
156
|
|
|
156
157
|
DELETE_DEVICES = "deleteDevices"
|
|
157
158
|
DEVICES_CREATED = "devicesCreated"
|
|
159
|
+
DEVICES_DELAYED = "devicesDelayed"
|
|
158
160
|
ERROR = "error"
|
|
159
161
|
HUB_REFRESHED = "hubDataPointRefreshed"
|
|
160
162
|
LIST_DEVICES = "listDevices"
|
|
@@ -172,6 +174,16 @@ class CallSource(StrEnum):
|
|
|
172
174
|
MANUAL_OR_SCHEDULED = "manual_or_scheduled"
|
|
173
175
|
|
|
174
176
|
|
|
177
|
+
class CalulatedParameter(StrEnum):
|
|
178
|
+
"""Enum with calculated Homematic parameters."""
|
|
179
|
+
|
|
180
|
+
APPARENT_TEMPERATURE = "APPARENT_TEMPERATURE"
|
|
181
|
+
DEW_POINT = "DEW_POINT"
|
|
182
|
+
FROST_POINT = "FROST_POINT"
|
|
183
|
+
OPERATING_VOLTAGE_LEVEL = "OPERATING_VOLTAGE_LEVEL"
|
|
184
|
+
VAPOR_CONCENTRATION = "VAPOR_CONCENTRATION"
|
|
185
|
+
|
|
186
|
+
|
|
175
187
|
class CentralUnitState(StrEnum):
|
|
176
188
|
"""Enum with central unit states."""
|
|
177
189
|
|
|
@@ -183,6 +195,13 @@ class CentralUnitState(StrEnum):
|
|
|
183
195
|
STOPPING = "stopping"
|
|
184
196
|
|
|
185
197
|
|
|
198
|
+
class CommandRxMode(StrEnum):
|
|
199
|
+
"""Enum for Homematic rx modes for commands."""
|
|
200
|
+
|
|
201
|
+
BURST = "BURST"
|
|
202
|
+
WAKEUP = "WAKEUP"
|
|
203
|
+
|
|
204
|
+
|
|
186
205
|
class DataOperationResult(Enum):
|
|
187
206
|
"""Enum with data operation results."""
|
|
188
207
|
|
|
@@ -321,16 +340,6 @@ class Operations(IntEnum):
|
|
|
321
340
|
EVENT = 4
|
|
322
341
|
|
|
323
342
|
|
|
324
|
-
class CalulatedParameter(StrEnum):
|
|
325
|
-
"""Enum with calculated Homematic parameters."""
|
|
326
|
-
|
|
327
|
-
APPARENT_TEMPERATURE = "APPARENT_TEMPERATURE"
|
|
328
|
-
DEW_POINT = "DEW_POINT"
|
|
329
|
-
FROST_POINT = "FROST_POINT"
|
|
330
|
-
OPERATING_VOLTAGE_LEVEL = "OPERATING_VOLTAGE_LEVEL"
|
|
331
|
-
VAPOR_CONCENTRATION = "VAPOR_CONCENTRATION"
|
|
332
|
-
|
|
333
|
-
|
|
334
343
|
class Parameter(StrEnum):
|
|
335
344
|
"""Enum with Homematic parameters."""
|
|
336
345
|
|
|
@@ -530,11 +539,14 @@ class RxMode(IntEnum):
|
|
|
530
539
|
LAZY_CONFIG = 16
|
|
531
540
|
|
|
532
541
|
|
|
533
|
-
class
|
|
534
|
-
"""Enum
|
|
542
|
+
class SourceOfDeviceCreation(StrEnum):
|
|
543
|
+
"""Enum with source of device creation."""
|
|
535
544
|
|
|
536
|
-
|
|
537
|
-
|
|
545
|
+
CACHE = "CACHE"
|
|
546
|
+
INIT = "INIT"
|
|
547
|
+
MANUAL = "MANUAL"
|
|
548
|
+
NEW = "NEW"
|
|
549
|
+
REFRESH = "REFRESH"
|
|
538
550
|
|
|
539
551
|
|
|
540
552
|
class SysvarType(StrEnum):
|
aiohomematic/decorators.py
CHANGED
|
@@ -117,78 +117,84 @@ def inspector[**P, R]( # noqa: C901
|
|
|
117
117
|
|
|
118
118
|
@wraps(func)
|
|
119
119
|
def wrap_sync_function(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
120
|
-
"""Wrap sync functions."""
|
|
120
|
+
"""Wrap sync functions with minimized per-call overhead."""
|
|
121
121
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
)
|
|
125
|
-
|
|
122
|
+
# Fast-path: avoid logger check and time call unless explicitly enabled
|
|
123
|
+
start_needed = measure_performance and _LOGGER_PERFORMANCE.isEnabledFor(level=logging.DEBUG)
|
|
124
|
+
start = monotonic() if start_needed else None
|
|
125
|
+
|
|
126
|
+
# Avoid repeated ContextVar.get() calls; only set/reset when needed
|
|
127
|
+
was_in_service = IN_SERVICE_VAR.get()
|
|
128
|
+
token = IN_SERVICE_VAR.set(True) if not was_in_service else None
|
|
129
|
+
context_obj = args[0] if args else None
|
|
126
130
|
try:
|
|
127
131
|
return_value: R = func(*args, **kwargs)
|
|
128
132
|
except BaseHomematicException as bhexc:
|
|
129
|
-
if token:
|
|
133
|
+
if token is not None:
|
|
130
134
|
IN_SERVICE_VAR.reset(token)
|
|
131
135
|
return handle_exception(
|
|
132
136
|
exc=bhexc,
|
|
133
137
|
func=func,
|
|
134
|
-
is_sub_service_call=
|
|
138
|
+
is_sub_service_call=was_in_service,
|
|
135
139
|
is_homematic=True,
|
|
136
|
-
context_obj=
|
|
140
|
+
context_obj=context_obj,
|
|
137
141
|
)
|
|
138
142
|
except Exception as exc:
|
|
139
|
-
if token:
|
|
143
|
+
if token is not None:
|
|
140
144
|
IN_SERVICE_VAR.reset(token)
|
|
141
145
|
return handle_exception(
|
|
142
146
|
exc=exc,
|
|
143
147
|
func=func,
|
|
144
|
-
is_sub_service_call=
|
|
148
|
+
is_sub_service_call=was_in_service,
|
|
145
149
|
is_homematic=False,
|
|
146
|
-
context_obj=
|
|
150
|
+
context_obj=context_obj,
|
|
147
151
|
)
|
|
148
152
|
else:
|
|
149
|
-
if token:
|
|
153
|
+
if token is not None:
|
|
150
154
|
IN_SERVICE_VAR.reset(token)
|
|
151
155
|
return return_value
|
|
152
156
|
finally:
|
|
153
|
-
if start:
|
|
157
|
+
if start is not None:
|
|
154
158
|
_log_performance_message(func, start, *args, **kwargs)
|
|
155
159
|
|
|
156
160
|
@wraps(func)
|
|
157
161
|
async def wrap_async_function(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
158
|
-
"""Wrap async functions."""
|
|
162
|
+
"""Wrap async functions with minimized per-call overhead."""
|
|
163
|
+
|
|
164
|
+
start_needed = measure_performance and _LOGGER_PERFORMANCE.isEnabledFor(level=logging.DEBUG)
|
|
165
|
+
start = monotonic() if start_needed else None
|
|
159
166
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
token = IN_SERVICE_VAR.set(True) if not IN_SERVICE_VAR.get() else None
|
|
167
|
+
was_in_service = IN_SERVICE_VAR.get()
|
|
168
|
+
token = IN_SERVICE_VAR.set(True) if not was_in_service else None
|
|
169
|
+
context_obj = args[0] if args else None
|
|
164
170
|
try:
|
|
165
|
-
return_value = await func(*args, **kwargs) # type: ignore[misc]
|
|
171
|
+
return_value = await func(*args, **kwargs) # type: ignore[misc]
|
|
166
172
|
except BaseHomematicException as bhexc:
|
|
167
|
-
if token:
|
|
173
|
+
if token is not None:
|
|
168
174
|
IN_SERVICE_VAR.reset(token)
|
|
169
175
|
return handle_exception(
|
|
170
176
|
exc=bhexc,
|
|
171
177
|
func=func,
|
|
172
|
-
is_sub_service_call=
|
|
178
|
+
is_sub_service_call=was_in_service,
|
|
173
179
|
is_homematic=True,
|
|
174
|
-
context_obj=
|
|
180
|
+
context_obj=context_obj,
|
|
175
181
|
)
|
|
176
182
|
except Exception as exc:
|
|
177
|
-
if token:
|
|
183
|
+
if token is not None:
|
|
178
184
|
IN_SERVICE_VAR.reset(token)
|
|
179
185
|
return handle_exception(
|
|
180
186
|
exc=exc,
|
|
181
187
|
func=func,
|
|
182
|
-
is_sub_service_call=
|
|
188
|
+
is_sub_service_call=was_in_service,
|
|
183
189
|
is_homematic=False,
|
|
184
|
-
context_obj=
|
|
190
|
+
context_obj=context_obj,
|
|
185
191
|
)
|
|
186
192
|
else:
|
|
187
|
-
if token:
|
|
193
|
+
if token is not None:
|
|
188
194
|
IN_SERVICE_VAR.reset(token)
|
|
189
195
|
return cast(R, return_value)
|
|
190
196
|
finally:
|
|
191
|
-
if start:
|
|
197
|
+
if start is not None:
|
|
192
198
|
_log_performance_message(func, start, *args, **kwargs)
|
|
193
199
|
|
|
194
200
|
# Check if the function is a coroutine or not and select the appropriate wrapper
|
|
@@ -105,7 +105,7 @@ class _GenericProperty[GETTER, SETTER](property):
|
|
|
105
105
|
func_name = fdel.__name__
|
|
106
106
|
else:
|
|
107
107
|
func_name = "prop"
|
|
108
|
-
self._cache_attr = f"_cached_{func_name}"
|
|
108
|
+
self._cache_attr = f"_cached_{func_name}"
|
|
109
109
|
|
|
110
110
|
def getter(self, fget: Callable[[Any], GETTER], /) -> _GenericProperty:
|
|
111
111
|
"""Return generic getter."""
|
|
@@ -153,25 +153,45 @@ class _GenericProperty[GETTER, SETTER](property):
|
|
|
153
153
|
if instance is None:
|
|
154
154
|
# Accessed from class, return the descriptor itself
|
|
155
155
|
return cast(GETTER, self)
|
|
156
|
-
|
|
156
|
+
|
|
157
|
+
if (fget := self.fget) is None:
|
|
157
158
|
raise AttributeError("unreadable attribute") # pragma: no cover
|
|
158
159
|
|
|
159
160
|
if not self._cached:
|
|
160
|
-
return
|
|
161
|
+
return fget(instance)
|
|
161
162
|
|
|
162
|
-
#
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
setattr(instance, self._cache_attr, value)
|
|
163
|
+
# Use direct __dict__ access when available for better performance
|
|
164
|
+
# Store cache_attr in local variable to avoid repeated attribute lookup
|
|
165
|
+
cache_attr = self._cache_attr
|
|
166
166
|
|
|
167
|
-
|
|
168
|
-
|
|
167
|
+
try:
|
|
168
|
+
inst_dict = instance.__dict__
|
|
169
|
+
# Use 'in' check first to distinguish between missing and None
|
|
170
|
+
if cache_attr in inst_dict:
|
|
171
|
+
return cast(GETTER, inst_dict[cache_attr])
|
|
172
|
+
|
|
173
|
+
# Not cached yet, compute and store
|
|
174
|
+
value = fget(instance)
|
|
175
|
+
inst_dict[cache_attr] = value
|
|
176
|
+
except AttributeError:
|
|
177
|
+
# Object uses __slots__, fall back to getattr/setattr
|
|
178
|
+
try:
|
|
179
|
+
return cast(GETTER, getattr(instance, cache_attr))
|
|
180
|
+
except AttributeError:
|
|
181
|
+
value = fget(instance)
|
|
182
|
+
setattr(instance, cache_attr, value)
|
|
183
|
+
return value
|
|
169
184
|
|
|
170
185
|
def __set__(self, instance: Any, value: Any, /) -> None:
|
|
171
186
|
"""Set the attribute value and invalidate cache if enabled."""
|
|
172
187
|
# Delete the cached value so it can be recomputed on next access.
|
|
173
|
-
if self._cached
|
|
174
|
-
|
|
188
|
+
if self._cached:
|
|
189
|
+
try:
|
|
190
|
+
instance.__dict__.pop(self._cache_attr, None)
|
|
191
|
+
except AttributeError:
|
|
192
|
+
# Object uses __slots__, fall back to delattr
|
|
193
|
+
if hasattr(instance, self._cache_attr):
|
|
194
|
+
delattr(instance, self._cache_attr)
|
|
175
195
|
|
|
176
196
|
if self.fset is None:
|
|
177
197
|
raise AttributeError("can't set attribute") # pragma: no cover
|
|
@@ -181,8 +201,13 @@ class _GenericProperty[GETTER, SETTER](property):
|
|
|
181
201
|
"""Delete the attribute and invalidate cache if enabled."""
|
|
182
202
|
|
|
183
203
|
# Delete the cached value so it can be recomputed on next access.
|
|
184
|
-
if self._cached
|
|
185
|
-
|
|
204
|
+
if self._cached:
|
|
205
|
+
try:
|
|
206
|
+
instance.__dict__.pop(self._cache_attr, None)
|
|
207
|
+
except AttributeError:
|
|
208
|
+
# Object uses __slots__, fall back to delattr
|
|
209
|
+
if hasattr(instance, self._cache_attr):
|
|
210
|
+
delattr(instance, self._cache_attr)
|
|
186
211
|
|
|
187
212
|
if self.fdel is None:
|
|
188
213
|
raise AttributeError("can't delete attribute") # pragma: no cover
|
aiohomematic/support.py
CHANGED
|
@@ -44,6 +44,7 @@ from aiohomematic.const import (
|
|
|
44
44
|
PRIMARY_CLIENT_CANDIDATE_INTERFACES,
|
|
45
45
|
TIMEOUT,
|
|
46
46
|
CommandRxMode,
|
|
47
|
+
DeviceDescription,
|
|
47
48
|
ParamsetKey,
|
|
48
49
|
RxMode,
|
|
49
50
|
SysvarType,
|
|
@@ -165,6 +166,19 @@ def check_or_create_directory(*, directory: str) -> bool:
|
|
|
165
166
|
return True
|
|
166
167
|
|
|
167
168
|
|
|
169
|
+
def extract_device_addresses_from_device_descriptions(
|
|
170
|
+
*, device_descriptions: tuple[DeviceDescription, ...]
|
|
171
|
+
) -> tuple[str, ...]:
|
|
172
|
+
"""Extract addresses from device descriptions."""
|
|
173
|
+
return tuple(
|
|
174
|
+
{
|
|
175
|
+
parent_address
|
|
176
|
+
for dev_desc in device_descriptions
|
|
177
|
+
if (parent_address := dev_desc.get("PARENT")) and (is_device_address(address=parent_address))
|
|
178
|
+
}
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
168
182
|
def parse_sys_var(*, data_type: SysvarType | None, raw_value: Any) -> Any:
|
|
169
183
|
"""Parse system variables to fix type."""
|
|
170
184
|
if not data_type:
|
|
@@ -352,6 +366,45 @@ def is_port(*, port: int) -> bool:
|
|
|
352
366
|
return 0 <= port <= 65535
|
|
353
367
|
|
|
354
368
|
|
|
369
|
+
@lru_cache(maxsize=2048)
|
|
370
|
+
def _element_matches_key_cached(
|
|
371
|
+
*,
|
|
372
|
+
search_elements: tuple[str, ...] | str,
|
|
373
|
+
compare_with: str,
|
|
374
|
+
ignore_case: bool,
|
|
375
|
+
do_left_wildcard_search: bool,
|
|
376
|
+
do_right_wildcard_search: bool,
|
|
377
|
+
) -> bool:
|
|
378
|
+
"""Cache element matching for hashable inputs."""
|
|
379
|
+
compare_with_processed = compare_with.lower() if ignore_case else compare_with
|
|
380
|
+
|
|
381
|
+
if isinstance(search_elements, str):
|
|
382
|
+
element = search_elements.lower() if ignore_case else search_elements
|
|
383
|
+
if do_left_wildcard_search is True and do_right_wildcard_search is True:
|
|
384
|
+
return element in compare_with_processed
|
|
385
|
+
if do_left_wildcard_search:
|
|
386
|
+
return compare_with_processed.endswith(element)
|
|
387
|
+
if do_right_wildcard_search:
|
|
388
|
+
return compare_with_processed.startswith(element)
|
|
389
|
+
return compare_with_processed == element
|
|
390
|
+
|
|
391
|
+
# search_elements is a tuple
|
|
392
|
+
for item in search_elements:
|
|
393
|
+
element = item.lower() if ignore_case else item
|
|
394
|
+
if do_left_wildcard_search is True and do_right_wildcard_search is True:
|
|
395
|
+
if element in compare_with_processed:
|
|
396
|
+
return True
|
|
397
|
+
elif do_left_wildcard_search:
|
|
398
|
+
if compare_with_processed.endswith(element):
|
|
399
|
+
return True
|
|
400
|
+
elif do_right_wildcard_search:
|
|
401
|
+
if compare_with_processed.startswith(element):
|
|
402
|
+
return True
|
|
403
|
+
elif compare_with_processed == element:
|
|
404
|
+
return True
|
|
405
|
+
return False
|
|
406
|
+
|
|
407
|
+
|
|
355
408
|
def element_matches_key(
|
|
356
409
|
*,
|
|
357
410
|
search_elements: str | Collection[str],
|
|
@@ -371,38 +424,48 @@ def element_matches_key(
|
|
|
371
424
|
if compare_with is None or not search_elements:
|
|
372
425
|
return False
|
|
373
426
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
element = search_elements.lower() if ignore_case else search_elements
|
|
378
|
-
if do_left_wildcard_search is True and do_right_wildcard_search is True:
|
|
379
|
-
return element in compare_with
|
|
380
|
-
if do_left_wildcard_search:
|
|
381
|
-
return compare_with.endswith(element)
|
|
382
|
-
if do_right_wildcard_search:
|
|
383
|
-
return compare_with.startswith(element)
|
|
384
|
-
return compare_with == element
|
|
385
|
-
if isinstance(search_elements, Collection):
|
|
386
|
-
if isinstance(search_elements, dict) and (
|
|
387
|
-
match_key := _get_search_key(search_elements=search_elements, search_key=search_key) if search_key else None
|
|
388
|
-
):
|
|
427
|
+
# Handle dict case with search_key
|
|
428
|
+
if isinstance(search_elements, dict) and search_key:
|
|
429
|
+
if match_key := _get_search_key(search_elements=search_elements, search_key=search_key):
|
|
389
430
|
if (elements := search_elements.get(match_key)) is None:
|
|
390
431
|
return False
|
|
391
432
|
search_elements = elements
|
|
433
|
+
else:
|
|
434
|
+
return False
|
|
435
|
+
|
|
436
|
+
search_elements_hashable: str | Collection[str]
|
|
437
|
+
# Convert to hashable types for caching
|
|
438
|
+
if isinstance(search_elements, str):
|
|
439
|
+
search_elements_hashable = search_elements
|
|
440
|
+
elif isinstance(search_elements, (list, set)):
|
|
441
|
+
search_elements_hashable = tuple(search_elements)
|
|
442
|
+
elif isinstance(search_elements, tuple):
|
|
443
|
+
search_elements_hashable = search_elements
|
|
444
|
+
else:
|
|
445
|
+
# Fall back to non-cached version for other collection types
|
|
446
|
+
compare_with_processed = compare_with.lower() if ignore_case else compare_with
|
|
392
447
|
for item in search_elements:
|
|
393
448
|
element = item.lower() if ignore_case else item
|
|
394
449
|
if do_left_wildcard_search is True and do_right_wildcard_search is True:
|
|
395
|
-
if element in
|
|
450
|
+
if element in compare_with_processed:
|
|
396
451
|
return True
|
|
397
452
|
elif do_left_wildcard_search:
|
|
398
|
-
if
|
|
453
|
+
if compare_with_processed.endswith(element):
|
|
399
454
|
return True
|
|
400
455
|
elif do_right_wildcard_search:
|
|
401
|
-
if
|
|
456
|
+
if compare_with_processed.startswith(element):
|
|
402
457
|
return True
|
|
403
|
-
elif
|
|
458
|
+
elif compare_with_processed == element:
|
|
404
459
|
return True
|
|
405
|
-
|
|
460
|
+
return False
|
|
461
|
+
|
|
462
|
+
return _element_matches_key_cached(
|
|
463
|
+
search_elements=search_elements_hashable,
|
|
464
|
+
compare_with=compare_with,
|
|
465
|
+
ignore_case=ignore_case,
|
|
466
|
+
do_left_wildcard_search=do_left_wildcard_search,
|
|
467
|
+
do_right_wildcard_search=do_right_wildcard_search,
|
|
468
|
+
)
|
|
406
469
|
|
|
407
470
|
|
|
408
471
|
def _get_search_key(*, search_elements: Collection[str], search_key: str) -> str | None:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aiohomematic
|
|
3
|
-
Version: 2025.10.
|
|
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>
|
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
aiohomematic/__init__.py,sha256=ngULK_anZQwwUUCVcberBdVjguYfboiuG9VoueKy9fA,2283
|
|
2
|
-
aiohomematic/async_support.py,sha256=
|
|
3
|
-
aiohomematic/const.py,sha256=
|
|
2
|
+
aiohomematic/async_support.py,sha256=BeNKaDrFsRA5-_uAFzmyyKPqlImfSs58C22Nqd5dZAg,7887
|
|
3
|
+
aiohomematic/const.py,sha256=d_9gr5SrIwInmN0UTV3RJZ76z-HcxlVIZ1OuirBck4Y,25840
|
|
4
4
|
aiohomematic/context.py,sha256=M7gkA7KFT0dp35gzGz2dzKVXu1PP0sAnepgLlmjyRS4,451
|
|
5
5
|
aiohomematic/converter.py,sha256=gaNHe-WEiBStZMuuRz9iGn3Mo_CGz1bjgLtlYBJJAko,3624
|
|
6
|
-
aiohomematic/decorators.py,sha256=
|
|
6
|
+
aiohomematic/decorators.py,sha256=M4n_VSyqmsUgQQQv_-3JWQxYPbS6KEkhCS8OzAfaVKo,11060
|
|
7
7
|
aiohomematic/exceptions.py,sha256=8Uu3rADawhYlAz6y4J52aJ-wKok8Z7YbUYUwWeGMKhs,5028
|
|
8
8
|
aiohomematic/hmcli.py,sha256=qNstNDX6q8t3mJFCGlXlmRVobGabntrPtFi3kchf1Eg,4933
|
|
9
|
-
aiohomematic/property_decorators.py,sha256=
|
|
9
|
+
aiohomematic/property_decorators.py,sha256=56lHGATgRtaFkIK_IXcR2tBW9mIVITcCwH5KOw575GA,17162
|
|
10
10
|
aiohomematic/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
-
aiohomematic/support.py,sha256=
|
|
11
|
+
aiohomematic/support.py,sha256=PMiE_8MXDQBIqeJGw1GIS7O3jGY4sq3HT-u1MIyOG-M,22802
|
|
12
12
|
aiohomematic/validator.py,sha256=HUikmo-SFksehFBAdZmBv4ajy0XkjgvXvcCfbexnzZo,3563
|
|
13
13
|
aiohomematic/caches/__init__.py,sha256=_gI30tbsWgPRaHvP6cRxOQr6n9bYZzU-jp1WbHhWg-A,470
|
|
14
|
-
aiohomematic/caches/dynamic.py,sha256=
|
|
15
|
-
aiohomematic/caches/persistent.py,sha256=
|
|
14
|
+
aiohomematic/caches/dynamic.py,sha256=0hOu-WoYUc9_3fofMeg_OjlYS-quD4uTyDI6zd5W4Do,22553
|
|
15
|
+
aiohomematic/caches/persistent.py,sha256=xUMjvu5Vthz9W0LLllSbcqTADZvVV025b4VnPzrPnis,20604
|
|
16
16
|
aiohomematic/caches/visibility.py,sha256=SThfEO3LKbIIERD4Novyj4ZZUkcYrBuvYdNQfPO29JQ,31676
|
|
17
|
-
aiohomematic/central/__init__.py,sha256=
|
|
17
|
+
aiohomematic/central/__init__.py,sha256=Jh0CH_gj1zsBaYYBRm9Ue1-f8qf2TV62jcMWuYoX-xI,92241
|
|
18
18
|
aiohomematic/central/decorators.py,sha256=v0gCa5QEQPNEvGy0O2YIOhoJy7OKiJZJWtK64OQm7y4,6918
|
|
19
19
|
aiohomematic/central/xml_rpc_server.py,sha256=1Vr8BXakLSo-RC967IlRI9LPUCl4iXp2y-AXSZuDyPc,10777
|
|
20
|
-
aiohomematic/client/__init__.py,sha256=
|
|
20
|
+
aiohomematic/client/__init__.py,sha256=KcqPEsqYF6L5RoW3LoKd83GqyM2F8W6LkA_cBG_sLiw,71519
|
|
21
21
|
aiohomematic/client/_rpc_errors.py,sha256=-NPtGvkQPJ4V2clDxv1tKy09M9JZm61pUCeki9DDh6s,2984
|
|
22
|
-
aiohomematic/client/json_rpc.py,sha256=
|
|
22
|
+
aiohomematic/client/json_rpc.py,sha256=DSTmKB7YdxVmOH5b7HKoN_H-fI837n0CpyPdJyurans,50174
|
|
23
23
|
aiohomematic/client/xml_rpc.py,sha256=9PEOWoTG0EMUAMyqiInF4iA_AduHv_hhjJW3YhYjYqU,9614
|
|
24
24
|
aiohomematic/model/__init__.py,sha256=KO7gas_eEzm67tODKqWTs0617CSGeKKjOWOlDbhRo_Q,5458
|
|
25
25
|
aiohomematic/model/data_point.py,sha256=Ml8AOQ1RcRezTYWiGBlIXwcTLolQMX5Cyb-O7GtNDm4,41586
|
|
@@ -69,10 +69,10 @@ aiohomematic/rega_scripts/get_serial.fn,sha256=t1oeo-sB_EuVeiY24PLcxFSkdQVgEWGXz
|
|
|
69
69
|
aiohomematic/rega_scripts/get_system_variable_descriptions.fn,sha256=UKXvC0_5lSApdQ2atJc0E5Stj5Zt3lqh0EcliokYu2c,849
|
|
70
70
|
aiohomematic/rega_scripts/set_program_state.fn,sha256=0bnv7lUj8FMjDZBz325tDVP61m04cHjVj4kIOnUUgpY,279
|
|
71
71
|
aiohomematic/rega_scripts/set_system_variable.fn,sha256=sTmr7vkPTPnPkor5cnLKlDvfsYRbGO1iq2z_2pMXq5E,383
|
|
72
|
-
aiohomematic-2025.10.
|
|
72
|
+
aiohomematic-2025.10.5.dist-info/licenses/LICENSE,sha256=q-B0xpREuZuvKsmk3_iyVZqvZ-vJcWmzMZpeAd0RqtQ,1083
|
|
73
73
|
aiohomematic_support/__init__.py,sha256=_0YtF4lTdC_k6-zrM2IefI0u0LMr_WA61gXAyeGLgbY,66
|
|
74
|
-
aiohomematic_support/client_local.py,sha256=
|
|
75
|
-
aiohomematic-2025.10.
|
|
76
|
-
aiohomematic-2025.10.
|
|
77
|
-
aiohomematic-2025.10.
|
|
78
|
-
aiohomematic-2025.10.
|
|
74
|
+
aiohomematic_support/client_local.py,sha256=nFeYkoX_EXXIwbrpL_5peYQG-934D0ASN6kflYp0_4I,12819
|
|
75
|
+
aiohomematic-2025.10.5.dist-info/METADATA,sha256=8x8jCa_ZHMKfTyNBstzj2oqgTvdjgcS_v9E74Urd5aQ,7603
|
|
76
|
+
aiohomematic-2025.10.5.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
77
|
+
aiohomematic-2025.10.5.dist-info/top_level.txt,sha256=5TDRlUWQPThIUwQjOj--aUo4UA-ow4m0sNhnoCBi5n8,34
|
|
78
|
+
aiohomematic-2025.10.5.dist-info/RECORD,,
|
|
@@ -35,7 +35,7 @@ from aiohomematic.decorators import inspector
|
|
|
35
35
|
from aiohomematic.support import is_channel_address
|
|
36
36
|
|
|
37
37
|
LOCAL_SERIAL: Final = "0815_4711"
|
|
38
|
-
BACKEND_LOCAL: Final = "
|
|
38
|
+
BACKEND_LOCAL: Final = "PyDevCCU"
|
|
39
39
|
|
|
40
40
|
|
|
41
41
|
class ClientLocal(Client): # pragma: no cover
|
|
File without changes
|
|
File without changes
|
|
File without changes
|