aiohomematic 2025.8.9__py3-none-any.whl → 2025.8.10__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of aiohomematic might be problematic. Click here for more details.
- aiohomematic/__init__.py +15 -1
- aiohomematic/async_support.py +15 -2
- aiohomematic/caches/__init__.py +2 -0
- aiohomematic/caches/dynamic.py +2 -0
- aiohomematic/caches/persistent.py +2 -0
- aiohomematic/caches/visibility.py +2 -0
- aiohomematic/central/__init__.py +43 -18
- aiohomematic/central/decorators.py +60 -15
- aiohomematic/central/xml_rpc_server.py +15 -1
- aiohomematic/client/__init__.py +2 -0
- aiohomematic/client/_rpc_errors.py +81 -0
- aiohomematic/client/json_rpc.py +68 -19
- aiohomematic/client/xml_rpc.py +15 -8
- aiohomematic/const.py +44 -3
- aiohomematic/context.py +11 -1
- aiohomematic/converter.py +27 -1
- aiohomematic/decorators.py +88 -19
- aiohomematic/exceptions.py +19 -1
- aiohomematic/hmcli.py +13 -1
- aiohomematic/model/__init__.py +2 -0
- aiohomematic/model/calculated/__init__.py +2 -0
- aiohomematic/model/calculated/climate.py +2 -0
- aiohomematic/model/calculated/data_point.py +2 -0
- aiohomematic/model/calculated/operating_voltage_level.py +2 -0
- aiohomematic/model/calculated/support.py +2 -0
- aiohomematic/model/custom/__init__.py +2 -0
- aiohomematic/model/custom/climate.py +3 -1
- aiohomematic/model/custom/const.py +2 -0
- aiohomematic/model/custom/cover.py +30 -2
- aiohomematic/model/custom/data_point.py +2 -0
- aiohomematic/model/custom/definition.py +2 -0
- aiohomematic/model/custom/light.py +18 -10
- aiohomematic/model/custom/lock.py +2 -0
- aiohomematic/model/custom/siren.py +5 -2
- aiohomematic/model/custom/support.py +2 -0
- aiohomematic/model/custom/switch.py +2 -0
- aiohomematic/model/custom/valve.py +2 -0
- aiohomematic/model/data_point.py +15 -3
- aiohomematic/model/decorators.py +29 -8
- aiohomematic/model/device.py +2 -0
- aiohomematic/model/event.py +2 -0
- aiohomematic/model/generic/__init__.py +2 -0
- aiohomematic/model/generic/action.py +2 -0
- aiohomematic/model/generic/binary_sensor.py +2 -0
- aiohomematic/model/generic/button.py +2 -0
- aiohomematic/model/generic/data_point.py +4 -1
- aiohomematic/model/generic/number.py +4 -1
- aiohomematic/model/generic/select.py +4 -1
- aiohomematic/model/generic/sensor.py +2 -0
- aiohomematic/model/generic/switch.py +2 -0
- aiohomematic/model/generic/text.py +2 -0
- aiohomematic/model/hub/__init__.py +2 -0
- aiohomematic/model/hub/binary_sensor.py +2 -0
- aiohomematic/model/hub/button.py +2 -0
- aiohomematic/model/hub/data_point.py +2 -0
- aiohomematic/model/hub/number.py +2 -0
- aiohomematic/model/hub/select.py +2 -0
- aiohomematic/model/hub/sensor.py +2 -0
- aiohomematic/model/hub/switch.py +2 -0
- aiohomematic/model/hub/text.py +2 -0
- aiohomematic/model/support.py +26 -1
- aiohomematic/model/update.py +2 -0
- aiohomematic/support.py +160 -3
- aiohomematic/validator.py +49 -2
- aiohomematic-2025.8.10.dist-info/METADATA +124 -0
- aiohomematic-2025.8.10.dist-info/RECORD +78 -0
- {aiohomematic-2025.8.9.dist-info → aiohomematic-2025.8.10.dist-info}/licenses/LICENSE +1 -1
- aiohomematic-2025.8.9.dist-info/METADATA +0 -69
- aiohomematic-2025.8.9.dist-info/RECORD +0 -77
- {aiohomematic-2025.8.9.dist-info → aiohomematic-2025.8.10.dist-info}/WHEEL +0 -0
- {aiohomematic-2025.8.9.dist-info → aiohomematic-2025.8.10.dist-info}/top_level.txt +0 -0
aiohomematic/__init__.py
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2021-2025 Daniel Perna, SukramJ
|
|
1
3
|
"""
|
|
2
4
|
AioHomematic: a Python 3 library to interact with HomeMatic and HomematicIP backends.
|
|
3
5
|
|
|
6
|
+
Public API at the top-level package is defined by __all__.
|
|
7
|
+
|
|
4
8
|
This package provides a high-level API to discover devices and channels, read and write
|
|
5
9
|
parameters (data points), receive events, and manage programs and system variables.
|
|
6
10
|
|
|
@@ -23,7 +27,7 @@ import sys
|
|
|
23
27
|
import threading
|
|
24
28
|
from typing import Final
|
|
25
29
|
|
|
26
|
-
from aiohomematic import central as hmcu
|
|
30
|
+
from aiohomematic import central as hmcu, validator as _ahm_validator
|
|
27
31
|
from aiohomematic.const import VERSION
|
|
28
32
|
|
|
29
33
|
if sys.stdout.isatty():
|
|
@@ -43,5 +47,15 @@ def signal_handler(sig, frame): # type: ignore[no-untyped-def]
|
|
|
43
47
|
asyncio.run_coroutine_threadsafe(central.stop(), asyncio.get_running_loop())
|
|
44
48
|
|
|
45
49
|
|
|
50
|
+
# Perform lightweight startup validation once on import
|
|
51
|
+
try:
|
|
52
|
+
_ahm_validator.validate_startup()
|
|
53
|
+
except Exception as _exc: # pragma: no cover
|
|
54
|
+
# Fail-fast with a clear message if validation fails during import
|
|
55
|
+
raise RuntimeError(f"AioHomematic startup validation failed: {_exc}") from _exc
|
|
56
|
+
|
|
46
57
|
if threading.current_thread() is threading.main_thread() and sys.stdout.isatty():
|
|
47
58
|
signal.signal(signal.SIGINT, signal_handler)
|
|
59
|
+
|
|
60
|
+
# Define public API for the top-level package
|
|
61
|
+
__all__ = ["__version__"]
|
aiohomematic/async_support.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2021-2025 Daniel Perna, SukramJ
|
|
1
3
|
"""Module with support for loop interaction."""
|
|
2
4
|
|
|
3
5
|
from __future__ import annotations
|
|
@@ -26,13 +28,24 @@ class Looper:
|
|
|
26
28
|
self._tasks: Final[set[asyncio.Future[Any]]] = set()
|
|
27
29
|
self._loop = asyncio.get_event_loop()
|
|
28
30
|
|
|
29
|
-
async def block_till_done(self) -> None:
|
|
30
|
-
"""
|
|
31
|
+
async def block_till_done(self, wait_time: float | None = None) -> None:
|
|
32
|
+
"""
|
|
33
|
+
Block until all pending work is done.
|
|
34
|
+
|
|
35
|
+
If wait_time is set, stop waiting after the given number of seconds and log remaining tasks.
|
|
36
|
+
"""
|
|
31
37
|
# To flush out any call_soon_threadsafe
|
|
32
38
|
await asyncio.sleep(0)
|
|
33
39
|
start_time: float | None = None
|
|
40
|
+
deadline: float | None = (monotonic() + wait_time) if wait_time is not None else None
|
|
34
41
|
current_task = asyncio.current_task()
|
|
35
42
|
while tasks := [task for task in self._tasks if task is not current_task and not cancelling(task)]:
|
|
43
|
+
# If we have a deadline and have exceeded it, log remaining tasks and break
|
|
44
|
+
if deadline is not None and monotonic() >= deadline:
|
|
45
|
+
for task in tasks:
|
|
46
|
+
_LOGGER.warning("Shutdown timeout reached; task still pending: %s", task)
|
|
47
|
+
break
|
|
48
|
+
|
|
36
49
|
await self._await_and_log_pending(tasks)
|
|
37
50
|
|
|
38
51
|
if start_time is None:
|
aiohomematic/caches/__init__.py
CHANGED
aiohomematic/caches/dynamic.py
CHANGED
aiohomematic/central/__init__.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2021-2025 Daniel Perna, SukramJ
|
|
1
3
|
"""
|
|
2
4
|
Central unit and core orchestration for HomeMatic CCU and compatible backends.
|
|
3
5
|
|
|
@@ -119,6 +121,7 @@ from aiohomematic.const import (
|
|
|
119
121
|
TIMEOUT,
|
|
120
122
|
UN_IGNORE_WILDCARD,
|
|
121
123
|
BackendSystemEvent,
|
|
124
|
+
CentralUnitState,
|
|
122
125
|
DataOperationResult,
|
|
123
126
|
DataPointCategory,
|
|
124
127
|
DataPointKey,
|
|
@@ -163,6 +166,7 @@ from aiohomematic.support import check_config, extract_exc_args, get_channel_no,
|
|
|
163
166
|
__all__ = ["CentralConfig", "CentralUnit", "INTERFACE_EVENT_SCHEMA"]
|
|
164
167
|
|
|
165
168
|
_LOGGER: Final = logging.getLogger(__name__)
|
|
169
|
+
_LOGGER_EVENT: Final = logging.getLogger(f"{__name__}_event")
|
|
166
170
|
|
|
167
171
|
# {central_name, central}
|
|
168
172
|
CENTRAL_INSTANCES: Final[dict[str, CentralUnit]] = {}
|
|
@@ -184,7 +188,7 @@ class CentralUnit(PayloadMixin):
|
|
|
184
188
|
|
|
185
189
|
def __init__(self, central_config: CentralConfig) -> None:
|
|
186
190
|
"""Init the central unit."""
|
|
187
|
-
self.
|
|
191
|
+
self._state: CentralUnitState = CentralUnitState.NEW
|
|
188
192
|
self._clients_started: bool = False
|
|
189
193
|
self._device_add_semaphore: Final = asyncio.Semaphore()
|
|
190
194
|
self._connection_state: Final = CentralConnectionState()
|
|
@@ -379,9 +383,9 @@ class CentralUnit(PayloadMixin):
|
|
|
379
383
|
)
|
|
380
384
|
|
|
381
385
|
@property
|
|
382
|
-
def
|
|
383
|
-
"""Return
|
|
384
|
-
return self.
|
|
386
|
+
def state(self) -> CentralUnitState:
|
|
387
|
+
"""Return the central state."""
|
|
388
|
+
return self._state
|
|
385
389
|
|
|
386
390
|
@property
|
|
387
391
|
def supports_ping_pong(self) -> bool:
|
|
@@ -455,9 +459,17 @@ class CentralUnit(PayloadMixin):
|
|
|
455
459
|
async def start(self) -> None:
|
|
456
460
|
"""Start processing of the central unit."""
|
|
457
461
|
|
|
458
|
-
|
|
462
|
+
_LOGGER.debug("START: Central %s is %s", self.name, self._state)
|
|
463
|
+
if self._state == CentralUnitState.INITIALIZING:
|
|
464
|
+
_LOGGER.debug("START: Central %s already starting", self.name)
|
|
465
|
+
return
|
|
466
|
+
|
|
467
|
+
if self._state == CentralUnitState.RUNNING:
|
|
459
468
|
_LOGGER.debug("START: Central %s already started", self.name)
|
|
460
469
|
return
|
|
470
|
+
|
|
471
|
+
self._state = CentralUnitState.INITIALIZING
|
|
472
|
+
_LOGGER.debug("START: Initializing Central %s", self.name)
|
|
461
473
|
if self._config.enabled_interface_configs and (
|
|
462
474
|
ip_addr := await self._identify_ip_addr(port=self._config.connection_check_port)
|
|
463
475
|
):
|
|
@@ -479,6 +491,7 @@ class CentralUnit(PayloadMixin):
|
|
|
479
491
|
self._listen_port = xml_rpc_server.listen_port
|
|
480
492
|
self._xml_rpc_server.add_central(self)
|
|
481
493
|
except OSError as oserr:
|
|
494
|
+
self._state = CentralUnitState.STOPPED_BY_ERROR
|
|
482
495
|
raise AioHomematicException(
|
|
483
496
|
f"START: Failed to start central unit {self.name}: {extract_exc_args(exc=oserr)}"
|
|
484
497
|
) from oserr
|
|
@@ -492,13 +505,24 @@ class CentralUnit(PayloadMixin):
|
|
|
492
505
|
if self._config.enable_server:
|
|
493
506
|
self._start_scheduler()
|
|
494
507
|
|
|
495
|
-
self.
|
|
508
|
+
self._state = CentralUnitState.RUNNING
|
|
509
|
+
_LOGGER.debug("START: Central %s is %s", self.name, self._state)
|
|
496
510
|
|
|
497
511
|
async def stop(self) -> None:
|
|
498
512
|
"""Stop processing of the central unit."""
|
|
499
|
-
|
|
513
|
+
_LOGGER.debug("STOP: Central %s is %s", self.name, self._state)
|
|
514
|
+
if self._state == CentralUnitState.STOPPING:
|
|
515
|
+
_LOGGER.debug("STOP: Central %s is already stopping", self.name)
|
|
516
|
+
return
|
|
517
|
+
if self._state == CentralUnitState.STOPPED:
|
|
518
|
+
_LOGGER.debug("STOP: Central %s is already stopped", self.name)
|
|
519
|
+
return
|
|
520
|
+
if self._state != CentralUnitState.RUNNING:
|
|
500
521
|
_LOGGER.debug("STOP: Central %s not started", self.name)
|
|
501
522
|
return
|
|
523
|
+
self._state = CentralUnitState.STOPPING
|
|
524
|
+
_LOGGER.debug("STOP: Stopping Central %s", self.name)
|
|
525
|
+
|
|
502
526
|
await self.save_caches(save_device_descriptions=True, save_paramset_descriptions=True)
|
|
503
527
|
self._stop_scheduler()
|
|
504
528
|
await self._stop_clients()
|
|
@@ -522,8 +546,8 @@ class CentralUnit(PayloadMixin):
|
|
|
522
546
|
|
|
523
547
|
# cancel outstanding tasks to speed up teardown
|
|
524
548
|
self.looper.cancel_tasks()
|
|
525
|
-
# wait until tasks are finished
|
|
526
|
-
await self.looper.block_till_done()
|
|
549
|
+
# wait until tasks are finished (with wait_time safeguard)
|
|
550
|
+
await self.looper.block_till_done(wait_time=5.0)
|
|
527
551
|
|
|
528
552
|
# Wait briefly for any auxiliary threads to finish without blocking forever
|
|
529
553
|
max_wait_seconds = 5.0
|
|
@@ -532,7 +556,8 @@ class CentralUnit(PayloadMixin):
|
|
|
532
556
|
while self._has_active_threads and waited < max_wait_seconds:
|
|
533
557
|
await asyncio.sleep(interval)
|
|
534
558
|
waited += interval
|
|
535
|
-
self.
|
|
559
|
+
self._state = CentralUnitState.STOPPED
|
|
560
|
+
_LOGGER.debug("STOP: Central %s is %s", self.name, self._state)
|
|
536
561
|
|
|
537
562
|
async def restart_clients(self) -> None:
|
|
538
563
|
"""Restart clients."""
|
|
@@ -1074,7 +1099,7 @@ class CentralUnit(PayloadMixin):
|
|
|
1074
1099
|
@callback_event
|
|
1075
1100
|
async def data_point_event(self, interface_id: str, channel_address: str, parameter: str, value: Any) -> None:
|
|
1076
1101
|
"""If a device emits some sort event, we will handle it here."""
|
|
1077
|
-
|
|
1102
|
+
_LOGGER_EVENT.debug(
|
|
1078
1103
|
"EVENT: interface_id = %s, channel_address = %s, parameter = %s, value = %s",
|
|
1079
1104
|
interface_id,
|
|
1080
1105
|
channel_address,
|
|
@@ -1112,7 +1137,7 @@ class CentralUnit(PayloadMixin):
|
|
|
1112
1137
|
if callable(callback_handler):
|
|
1113
1138
|
await callback_handler(value)
|
|
1114
1139
|
except RuntimeError as rterr: # pragma: no cover
|
|
1115
|
-
|
|
1140
|
+
_LOGGER_EVENT.debug(
|
|
1116
1141
|
"EVENT: RuntimeError [%s]. Failed to call callback for: %s, %s, %s",
|
|
1117
1142
|
extract_exc_args(exc=rterr),
|
|
1118
1143
|
interface_id,
|
|
@@ -1120,7 +1145,7 @@ class CentralUnit(PayloadMixin):
|
|
|
1120
1145
|
parameter,
|
|
1121
1146
|
)
|
|
1122
1147
|
except Exception as exc: # pragma: no cover
|
|
1123
|
-
|
|
1148
|
+
_LOGGER_EVENT.warning(
|
|
1124
1149
|
"EVENT failed: Unable to call callback for: %s, %s, %s, %s",
|
|
1125
1150
|
interface_id,
|
|
1126
1151
|
channel_address,
|
|
@@ -1130,7 +1155,7 @@ class CentralUnit(PayloadMixin):
|
|
|
1130
1155
|
|
|
1131
1156
|
def data_point_path_event(self, state_path: str, value: str) -> None:
|
|
1132
1157
|
"""If a device emits some sort event, we will handle it here."""
|
|
1133
|
-
|
|
1158
|
+
_LOGGER_EVENT.debug(
|
|
1134
1159
|
"DATA_POINT_PATH_EVENT: topic = %s, payload = %s",
|
|
1135
1160
|
state_path,
|
|
1136
1161
|
value,
|
|
@@ -1149,7 +1174,7 @@ class CentralUnit(PayloadMixin):
|
|
|
1149
1174
|
|
|
1150
1175
|
def sysvar_data_point_path_event(self, state_path: str, value: str) -> None:
|
|
1151
1176
|
"""If a device emits some sort event, we will handle it here."""
|
|
1152
|
-
|
|
1177
|
+
_LOGGER_EVENT.debug(
|
|
1153
1178
|
"SYSVAR_DATA_POINT_PATH_EVENT: topic = %s, payload = %s",
|
|
1154
1179
|
state_path,
|
|
1155
1180
|
value,
|
|
@@ -1161,13 +1186,13 @@ class CentralUnit(PayloadMixin):
|
|
|
1161
1186
|
if callable(callback_handler):
|
|
1162
1187
|
self._looper.create_task(callback_handler(value), name=f"sysvar-data-point-event-{state_path}")
|
|
1163
1188
|
except RuntimeError as rterr: # pragma: no cover
|
|
1164
|
-
|
|
1189
|
+
_LOGGER_EVENT.debug(
|
|
1165
1190
|
"EVENT: RuntimeError [%s]. Failed to call callback for: %s",
|
|
1166
1191
|
extract_exc_args(exc=rterr),
|
|
1167
1192
|
state_path,
|
|
1168
1193
|
)
|
|
1169
1194
|
except Exception as exc: # pragma: no cover
|
|
1170
|
-
|
|
1195
|
+
_LOGGER_EVENT.warning(
|
|
1171
1196
|
"EVENT failed: Unable to call callback for: %s, %s",
|
|
1172
1197
|
state_path,
|
|
1173
1198
|
extract_exc_args(exc=exc),
|
|
@@ -1621,7 +1646,7 @@ class _Scheduler(threading.Thread):
|
|
|
1621
1646
|
async def _run_scheduler_tasks(self) -> None:
|
|
1622
1647
|
"""Run all tasks."""
|
|
1623
1648
|
while self._active:
|
|
1624
|
-
if
|
|
1649
|
+
if self._central.state != CentralUnitState.RUNNING:
|
|
1625
1650
|
_LOGGER.debug("SCHEDULER: Waiting till central %s is started", self._central.name)
|
|
1626
1651
|
await asyncio.sleep(SCHEDULER_NOT_STARTED_SLEEP)
|
|
1627
1652
|
continue
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2021-2025 Daniel Perna, SukramJ
|
|
1
3
|
"""Decorators for central used within aiohomematic."""
|
|
2
4
|
|
|
3
5
|
from __future__ import annotations
|
|
@@ -17,6 +19,9 @@ from aiohomematic.support import extract_exc_args
|
|
|
17
19
|
|
|
18
20
|
_LOGGER: Final = logging.getLogger(__name__)
|
|
19
21
|
_INTERFACE_ID: Final = "interface_id"
|
|
22
|
+
_CHANNEL_ADDRESS: Final = "channel_address"
|
|
23
|
+
_PARAMETER: Final = "parameter"
|
|
24
|
+
_VALUE: Final = "value"
|
|
20
25
|
|
|
21
26
|
|
|
22
27
|
def callback_backend_system(system_event: BackendSystemEvent) -> Callable:
|
|
@@ -83,28 +88,68 @@ def callback_backend_system(system_event: BackendSystemEvent) -> Callable:
|
|
|
83
88
|
return decorator_backend_system_callback
|
|
84
89
|
|
|
85
90
|
|
|
86
|
-
def callback_event[**P, R](
|
|
87
|
-
func: Callable[P, R],
|
|
88
|
-
) -> Callable:
|
|
91
|
+
def callback_event[**P, R](func: Callable[P, R]) -> Callable:
|
|
89
92
|
"""Check if event_callback is set and call it AFTER original function."""
|
|
90
93
|
|
|
91
|
-
@wraps(func)
|
|
92
|
-
async def async_wrapper_event_callback(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
93
|
-
"""Wrap callback events."""
|
|
94
|
-
return_value = cast(R, await func(*args, **kwargs)) # type: ignore[misc]
|
|
95
|
-
_exec_event_callback(*args, **kwargs)
|
|
96
|
-
return return_value
|
|
97
|
-
|
|
98
94
|
def _exec_event_callback(*args: Any, **kwargs: Any) -> None:
|
|
99
95
|
"""Execute the callback for a data_point event."""
|
|
100
96
|
try:
|
|
101
|
-
|
|
102
|
-
interface_id: str
|
|
97
|
+
# Expected signature: (self, interface_id, channel_address, parameter, value)
|
|
98
|
+
interface_id: str
|
|
99
|
+
if len(args) > 1:
|
|
100
|
+
interface_id = cast(str, args[1])
|
|
101
|
+
channel_address = cast(str, args[2])
|
|
102
|
+
parameter = cast(str, args[3])
|
|
103
|
+
value = args[4] if len(args) > 4 else kwargs.get(_VALUE)
|
|
104
|
+
else:
|
|
105
|
+
interface_id = cast(str, kwargs[_INTERFACE_ID])
|
|
106
|
+
channel_address = cast(str, kwargs[_CHANNEL_ADDRESS])
|
|
107
|
+
parameter = cast(str, kwargs[_PARAMETER])
|
|
108
|
+
value = kwargs[_VALUE]
|
|
109
|
+
|
|
103
110
|
if client := hmcl.get_client(interface_id=interface_id):
|
|
104
111
|
client.modified_at = datetime.now()
|
|
105
|
-
client.central.fire_backend_parameter_callback(
|
|
112
|
+
client.central.fire_backend_parameter_callback(
|
|
113
|
+
interface_id=interface_id, channel_address=channel_address, parameter=parameter, value=value
|
|
114
|
+
)
|
|
106
115
|
except Exception as exc: # pragma: no cover
|
|
107
|
-
_LOGGER.warning("EXEC_DATA_POINT_EVENT_CALLBACK failed: Unable to
|
|
116
|
+
_LOGGER.warning("EXEC_DATA_POINT_EVENT_CALLBACK failed: Unable to process args/kwargs for event_callback")
|
|
108
117
|
raise AioHomematicException(f"args-exception event_callback [{extract_exc_args(exc=exc)}]") from exc
|
|
109
118
|
|
|
110
|
-
|
|
119
|
+
def _schedule_or_exec(*args: Any, **kwargs: Any) -> None:
|
|
120
|
+
"""Schedule event callback on central looper when possible, else execute inline."""
|
|
121
|
+
try:
|
|
122
|
+
# Prefer scheduling on the CentralUnit looper when available to avoid blocking hot path
|
|
123
|
+
unit = args[0]
|
|
124
|
+
if isinstance(unit, hmcu.CentralUnit):
|
|
125
|
+
unit.looper.create_task(
|
|
126
|
+
_async_wrap_sync(_exec_event_callback, *args, **kwargs),
|
|
127
|
+
name="wrapper_event_callback",
|
|
128
|
+
)
|
|
129
|
+
return
|
|
130
|
+
except Exception:
|
|
131
|
+
# Fall through to inline execution on any error
|
|
132
|
+
pass
|
|
133
|
+
_exec_event_callback(*args, **kwargs)
|
|
134
|
+
|
|
135
|
+
@wraps(func)
|
|
136
|
+
async def async_wrapper_event_callback(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
137
|
+
"""Wrap async callback events."""
|
|
138
|
+
return_value = cast(R, await func(*args, **kwargs)) # type: ignore[misc]
|
|
139
|
+
_schedule_or_exec(*args, **kwargs)
|
|
140
|
+
return return_value
|
|
141
|
+
|
|
142
|
+
@wraps(func)
|
|
143
|
+
def wrapper_event_callback(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
144
|
+
"""Wrap sync callback events."""
|
|
145
|
+
return_value = func(*args, **kwargs)
|
|
146
|
+
_schedule_or_exec(*args, **kwargs)
|
|
147
|
+
return return_value
|
|
148
|
+
|
|
149
|
+
# Helper to create a trivial coroutine from a sync callable
|
|
150
|
+
async def _async_wrap_sync(cb: Callable[..., None], *a: Any, **kw: Any) -> None:
|
|
151
|
+
cb(*a, **kw)
|
|
152
|
+
|
|
153
|
+
if inspect.iscoroutinefunction(func):
|
|
154
|
+
return async_wrapper_event_callback
|
|
155
|
+
return wrapper_event_callback
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2021-2025 Daniel Perna, SukramJ
|
|
1
3
|
"""
|
|
2
4
|
XML-RPC server module.
|
|
3
5
|
|
|
@@ -16,7 +18,7 @@ from xmlrpc.server import SimpleXMLRPCRequestHandler, SimpleXMLRPCServer
|
|
|
16
18
|
from aiohomematic import central as hmcu
|
|
17
19
|
from aiohomematic.central.decorators import callback_backend_system
|
|
18
20
|
from aiohomematic.const import IP_ANY_V4, PORT_ANY, BackendSystemEvent
|
|
19
|
-
from aiohomematic.support import find_free_port
|
|
21
|
+
from aiohomematic.support import find_free_port, log_boundary_error
|
|
20
22
|
|
|
21
23
|
_LOGGER: Final = logging.getLogger(__name__)
|
|
22
24
|
|
|
@@ -45,6 +47,18 @@ class RPCFunctions:
|
|
|
45
47
|
@callback_backend_system(system_event=BackendSystemEvent.ERROR)
|
|
46
48
|
def error(self, interface_id: str, error_code: str, msg: str) -> None:
|
|
47
49
|
"""When some error occurs the CCU / Homegear will send its error message here."""
|
|
50
|
+
# Structured boundary log (warning level). XML-RPC server received error notification.
|
|
51
|
+
try:
|
|
52
|
+
raise RuntimeError(str(msg))
|
|
53
|
+
except RuntimeError as err:
|
|
54
|
+
log_boundary_error(
|
|
55
|
+
logger=_LOGGER,
|
|
56
|
+
boundary="xml-rpc-server",
|
|
57
|
+
action="error",
|
|
58
|
+
err=err,
|
|
59
|
+
level=logging.WARNING,
|
|
60
|
+
context={"interface_id": interface_id, "error_code": int(error_code)},
|
|
61
|
+
)
|
|
48
62
|
_LOGGER.warning(
|
|
49
63
|
"ERROR failed: interface_id = %s, error_code = %i, message = %s",
|
|
50
64
|
interface_id,
|
aiohomematic/client/__init__.py
CHANGED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2021-2025 Daniel Perna, SukramJ
|
|
3
|
+
"""
|
|
4
|
+
Error mapping helpers for RPC transports.
|
|
5
|
+
|
|
6
|
+
This module centralizes small, transport-agnostic utilities to turn backend
|
|
7
|
+
errors into domain-specific exceptions with useful context. It is used by both
|
|
8
|
+
JSON-RPC and XML-RPC clients.
|
|
9
|
+
|
|
10
|
+
Key types and functions
|
|
11
|
+
- RpcContext: Lightweight context container that formats protocol/method/host
|
|
12
|
+
for readable error messages and logs.
|
|
13
|
+
- map_jsonrpc_error: Maps a JSON-RPC error object to an appropriate exception
|
|
14
|
+
(AuthFailure, InternalBackendException, ClientException).
|
|
15
|
+
- map_transport_error: Maps generic transport-level exceptions like OSError to
|
|
16
|
+
domain exceptions (NoConnectionException/ClientException).
|
|
17
|
+
- map_xmlrpc_fault: Maps XML-RPC faults to domain exceptions with context.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from collections.abc import Mapping
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from aiohomematic.exceptions import AuthFailure, ClientException, InternalBackendException, NoConnectionException
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True)
|
|
30
|
+
class RpcContext:
|
|
31
|
+
protocol: str
|
|
32
|
+
method: str
|
|
33
|
+
host: str | None = None
|
|
34
|
+
interface: str | None = None
|
|
35
|
+
params: Mapping[str, Any] | None = None
|
|
36
|
+
|
|
37
|
+
def fmt(self) -> str:
|
|
38
|
+
"""Format context for error messages."""
|
|
39
|
+
parts: list[str] = [f"protocol={self.protocol}", f"method={self.method}"]
|
|
40
|
+
if self.interface:
|
|
41
|
+
parts.append(f"interface={self.interface}")
|
|
42
|
+
if self.host:
|
|
43
|
+
parts.append(f"host={self.host}")
|
|
44
|
+
return ", ".join(parts)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def map_jsonrpc_error(error: Mapping[str, Any], ctx: RpcContext) -> Exception:
|
|
48
|
+
"""Map JSON-RPC error to exception."""
|
|
49
|
+
# JSON-RPC 2.0 like error: {code, message, data?}
|
|
50
|
+
code = int(error.get("code", 0))
|
|
51
|
+
message = str(error.get("message", ""))
|
|
52
|
+
# Enrich message with context
|
|
53
|
+
base_msg = f"{message} ({ctx.fmt()})"
|
|
54
|
+
|
|
55
|
+
# Map common codes
|
|
56
|
+
if message.startswith("access denied") or code in (401, -32001):
|
|
57
|
+
return AuthFailure(base_msg)
|
|
58
|
+
if "internal error" in message.lower() or code in (-32603, 500):
|
|
59
|
+
return InternalBackendException(base_msg)
|
|
60
|
+
# Generic client exception for others
|
|
61
|
+
return ClientException(base_msg)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def map_transport_error(exc: BaseException, ctx: RpcContext) -> Exception:
|
|
65
|
+
"""Map transport error to exception."""
|
|
66
|
+
msg = f"{exc} ({ctx.fmt()})"
|
|
67
|
+
if isinstance(exc, OSError):
|
|
68
|
+
return NoConnectionException(msg)
|
|
69
|
+
return ClientException(msg)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def map_xmlrpc_fault(code: int, fault_string: str, ctx: RpcContext) -> Exception:
|
|
73
|
+
"""Map XML-RPC fault to exception."""
|
|
74
|
+
# Enrich message with context
|
|
75
|
+
fault_msg = f"XMLRPC Fault {code}: {fault_string} ({ctx.fmt()})"
|
|
76
|
+
# Simple mappings
|
|
77
|
+
if "unauthorized" in fault_string.lower():
|
|
78
|
+
return AuthFailure(fault_msg)
|
|
79
|
+
if "internal" in fault_string.lower():
|
|
80
|
+
return InternalBackendException(fault_msg)
|
|
81
|
+
return ClientException(fault_msg)
|
aiohomematic/client/json_rpc.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2021-2025 Daniel Perna, SukramJ
|
|
1
3
|
"""
|
|
2
4
|
Asynchronous JSON-RPC client for HomeMatic CCU-compatible backends.
|
|
3
5
|
|
|
@@ -29,6 +31,7 @@ Notes
|
|
|
29
31
|
|
|
30
32
|
from __future__ import annotations
|
|
31
33
|
|
|
34
|
+
import asyncio
|
|
32
35
|
from asyncio import Semaphore
|
|
33
36
|
from collections.abc import Mapping
|
|
34
37
|
from datetime import datetime
|
|
@@ -54,6 +57,7 @@ import orjson
|
|
|
54
57
|
|
|
55
58
|
from aiohomematic import central as hmcu
|
|
56
59
|
from aiohomematic.async_support import Looper
|
|
60
|
+
from aiohomematic.client._rpc_errors import RpcContext, map_jsonrpc_error
|
|
57
61
|
from aiohomematic.const import (
|
|
58
62
|
ALWAYS_ENABLE_SYSVARS_BY_ID,
|
|
59
63
|
DEFAULT_INCLUDE_INTERNAL_PROGRAMS,
|
|
@@ -78,7 +82,6 @@ from aiohomematic.const import (
|
|
|
78
82
|
SysvarType,
|
|
79
83
|
)
|
|
80
84
|
from aiohomematic.exceptions import (
|
|
81
|
-
AuthFailure,
|
|
82
85
|
BaseHomematicException,
|
|
83
86
|
ClientException,
|
|
84
87
|
InternalBackendException,
|
|
@@ -91,6 +94,7 @@ from aiohomematic.support import (
|
|
|
91
94
|
element_matches_key,
|
|
92
95
|
extract_exc_args,
|
|
93
96
|
get_tls_context,
|
|
97
|
+
log_boundary_error,
|
|
94
98
|
parse_sys_var,
|
|
95
99
|
)
|
|
96
100
|
|
|
@@ -402,38 +406,59 @@ class JsonRpcAioHttpClient:
|
|
|
402
406
|
)
|
|
403
407
|
if method in _PARALLEL_EXECUTION_LIMITED_JSONRPC_METHODS:
|
|
404
408
|
async with self._sema:
|
|
405
|
-
if (response := await post_call()) is None:
|
|
409
|
+
if (response := await asyncio.shield(post_call())) is None:
|
|
406
410
|
raise ClientException("POST method failed with no response")
|
|
407
|
-
elif (response := await post_call()) is None:
|
|
411
|
+
elif (response := await asyncio.shield(post_call())) is None:
|
|
408
412
|
raise ClientException("POST method failed with no response")
|
|
409
413
|
|
|
410
414
|
if response.status == 200:
|
|
411
|
-
json_response = await self._get_json_reponse(response=response)
|
|
415
|
+
json_response = await asyncio.shield(self._get_json_reponse(response=response))
|
|
412
416
|
|
|
413
417
|
if error := json_response[_JsonKey.ERROR]:
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
418
|
+
# Map JSON-RPC error to actionable exception with context
|
|
419
|
+
ctx = RpcContext(protocol="json-rpc", method=str(method), host=self._url)
|
|
420
|
+
exc = map_jsonrpc_error(error=error, ctx=ctx)
|
|
421
|
+
# Structured boundary log at warning level (recoverable per-call failure)
|
|
422
|
+
log_boundary_error(
|
|
423
|
+
logger=_LOGGER,
|
|
424
|
+
boundary="json-rpc",
|
|
425
|
+
action=str(method),
|
|
426
|
+
err=exc,
|
|
427
|
+
level=logging.WARNING,
|
|
428
|
+
context={"url": self._url},
|
|
429
|
+
)
|
|
430
|
+
_LOGGER.debug("POST: %s", exc)
|
|
431
|
+
raise exc
|
|
425
432
|
|
|
426
433
|
return json_response
|
|
427
434
|
|
|
428
435
|
message = f"Status: {response.status}"
|
|
429
|
-
json_response = await self._get_json_reponse(response=response)
|
|
436
|
+
json_response = await asyncio.shield(self._get_json_reponse(response=response))
|
|
430
437
|
if error := json_response[_JsonKey.ERROR]:
|
|
431
|
-
|
|
432
|
-
|
|
438
|
+
ctx = RpcContext(protocol="json-rpc", method=str(method), host=self._url)
|
|
439
|
+
exc = map_jsonrpc_error(error=error, ctx=ctx)
|
|
440
|
+
log_boundary_error(
|
|
441
|
+
logger=_LOGGER,
|
|
442
|
+
boundary="json-rpc",
|
|
443
|
+
action=str(method),
|
|
444
|
+
err=exc,
|
|
445
|
+
level=logging.WARNING,
|
|
446
|
+
context={"url": self._url, "status": response.status},
|
|
447
|
+
)
|
|
448
|
+
raise exc
|
|
433
449
|
raise ClientException(message)
|
|
434
|
-
except BaseHomematicException:
|
|
450
|
+
except BaseHomematicException as bhe:
|
|
435
451
|
if method in (_JsonRpcMethod.SESSION_LOGIN, _JsonRpcMethod.SESSION_LOGOUT, _JsonRpcMethod.SESSION_RENEW):
|
|
436
452
|
self.clear_session()
|
|
453
|
+
# Domain error at boundary -> warning
|
|
454
|
+
log_boundary_error(
|
|
455
|
+
logger=_LOGGER,
|
|
456
|
+
boundary="json-rpc",
|
|
457
|
+
action=str(method),
|
|
458
|
+
err=bhe,
|
|
459
|
+
level=logging.WARNING,
|
|
460
|
+
context={"url": self._url},
|
|
461
|
+
)
|
|
437
462
|
raise
|
|
438
463
|
except ClientConnectorCertificateError as cccerr:
|
|
439
464
|
self.clear_session()
|
|
@@ -443,12 +468,36 @@ class JsonRpcAioHttpClient:
|
|
|
443
468
|
f"{message}. Possible reason: 'Automatic forwarding to HTTPS' is enabled in backend, "
|
|
444
469
|
f"but this integration is not configured to use TLS"
|
|
445
470
|
)
|
|
471
|
+
log_boundary_error(
|
|
472
|
+
logger=_LOGGER,
|
|
473
|
+
boundary="json-rpc",
|
|
474
|
+
action=str(method),
|
|
475
|
+
err=cccerr,
|
|
476
|
+
level=logging.ERROR,
|
|
477
|
+
context={"url": self._url},
|
|
478
|
+
)
|
|
446
479
|
raise ClientException(message) from cccerr
|
|
447
480
|
except (ClientError, OSError) as err:
|
|
448
481
|
self.clear_session()
|
|
482
|
+
log_boundary_error(
|
|
483
|
+
logger=_LOGGER,
|
|
484
|
+
boundary="json-rpc",
|
|
485
|
+
action=str(method),
|
|
486
|
+
err=err,
|
|
487
|
+
level=logging.ERROR,
|
|
488
|
+
context={"url": self._url},
|
|
489
|
+
)
|
|
449
490
|
raise NoConnectionException(err) from err
|
|
450
491
|
except (TypeError, Exception) as exc:
|
|
451
492
|
self.clear_session()
|
|
493
|
+
log_boundary_error(
|
|
494
|
+
logger=_LOGGER,
|
|
495
|
+
boundary="json-rpc",
|
|
496
|
+
action=str(method),
|
|
497
|
+
err=exc,
|
|
498
|
+
level=logging.ERROR,
|
|
499
|
+
context={"url": self._url},
|
|
500
|
+
)
|
|
452
501
|
raise ClientException(exc) from exc
|
|
453
502
|
|
|
454
503
|
async def _get_json_reponse(self, response: ClientResponse) -> dict[str, Any] | Any:
|