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.

Files changed (71) hide show
  1. aiohomematic/__init__.py +15 -1
  2. aiohomematic/async_support.py +15 -2
  3. aiohomematic/caches/__init__.py +2 -0
  4. aiohomematic/caches/dynamic.py +2 -0
  5. aiohomematic/caches/persistent.py +2 -0
  6. aiohomematic/caches/visibility.py +2 -0
  7. aiohomematic/central/__init__.py +43 -18
  8. aiohomematic/central/decorators.py +60 -15
  9. aiohomematic/central/xml_rpc_server.py +15 -1
  10. aiohomematic/client/__init__.py +2 -0
  11. aiohomematic/client/_rpc_errors.py +81 -0
  12. aiohomematic/client/json_rpc.py +68 -19
  13. aiohomematic/client/xml_rpc.py +15 -8
  14. aiohomematic/const.py +44 -3
  15. aiohomematic/context.py +11 -1
  16. aiohomematic/converter.py +27 -1
  17. aiohomematic/decorators.py +88 -19
  18. aiohomematic/exceptions.py +19 -1
  19. aiohomematic/hmcli.py +13 -1
  20. aiohomematic/model/__init__.py +2 -0
  21. aiohomematic/model/calculated/__init__.py +2 -0
  22. aiohomematic/model/calculated/climate.py +2 -0
  23. aiohomematic/model/calculated/data_point.py +2 -0
  24. aiohomematic/model/calculated/operating_voltage_level.py +2 -0
  25. aiohomematic/model/calculated/support.py +2 -0
  26. aiohomematic/model/custom/__init__.py +2 -0
  27. aiohomematic/model/custom/climate.py +3 -1
  28. aiohomematic/model/custom/const.py +2 -0
  29. aiohomematic/model/custom/cover.py +30 -2
  30. aiohomematic/model/custom/data_point.py +2 -0
  31. aiohomematic/model/custom/definition.py +2 -0
  32. aiohomematic/model/custom/light.py +18 -10
  33. aiohomematic/model/custom/lock.py +2 -0
  34. aiohomematic/model/custom/siren.py +5 -2
  35. aiohomematic/model/custom/support.py +2 -0
  36. aiohomematic/model/custom/switch.py +2 -0
  37. aiohomematic/model/custom/valve.py +2 -0
  38. aiohomematic/model/data_point.py +15 -3
  39. aiohomematic/model/decorators.py +29 -8
  40. aiohomematic/model/device.py +2 -0
  41. aiohomematic/model/event.py +2 -0
  42. aiohomematic/model/generic/__init__.py +2 -0
  43. aiohomematic/model/generic/action.py +2 -0
  44. aiohomematic/model/generic/binary_sensor.py +2 -0
  45. aiohomematic/model/generic/button.py +2 -0
  46. aiohomematic/model/generic/data_point.py +4 -1
  47. aiohomematic/model/generic/number.py +4 -1
  48. aiohomematic/model/generic/select.py +4 -1
  49. aiohomematic/model/generic/sensor.py +2 -0
  50. aiohomematic/model/generic/switch.py +2 -0
  51. aiohomematic/model/generic/text.py +2 -0
  52. aiohomematic/model/hub/__init__.py +2 -0
  53. aiohomematic/model/hub/binary_sensor.py +2 -0
  54. aiohomematic/model/hub/button.py +2 -0
  55. aiohomematic/model/hub/data_point.py +2 -0
  56. aiohomematic/model/hub/number.py +2 -0
  57. aiohomematic/model/hub/select.py +2 -0
  58. aiohomematic/model/hub/sensor.py +2 -0
  59. aiohomematic/model/hub/switch.py +2 -0
  60. aiohomematic/model/hub/text.py +2 -0
  61. aiohomematic/model/support.py +26 -1
  62. aiohomematic/model/update.py +2 -0
  63. aiohomematic/support.py +160 -3
  64. aiohomematic/validator.py +49 -2
  65. aiohomematic-2025.8.10.dist-info/METADATA +124 -0
  66. aiohomematic-2025.8.10.dist-info/RECORD +78 -0
  67. {aiohomematic-2025.8.9.dist-info → aiohomematic-2025.8.10.dist-info}/licenses/LICENSE +1 -1
  68. aiohomematic-2025.8.9.dist-info/METADATA +0 -69
  69. aiohomematic-2025.8.9.dist-info/RECORD +0 -77
  70. {aiohomematic-2025.8.9.dist-info → aiohomematic-2025.8.10.dist-info}/WHEEL +0 -0
  71. {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__"]
@@ -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
- """Block until all pending work is done."""
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:
@@ -1,3 +1,5 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025 Daniel Perna, SukramJ
1
3
  """
2
4
  Cache packages for AioHomematic.
3
5
 
@@ -1,3 +1,5 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025 Daniel Perna, SukramJ
1
3
  """
2
4
  Dynamic caches used at runtime by the central unit and clients.
3
5
 
@@ -1,3 +1,5 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025 Daniel Perna, SukramJ
1
3
  """
2
4
  Persistent caches used to persist HomeMatic metadata between runs.
3
5
 
@@ -1,3 +1,5 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025 Daniel Perna, SukramJ
1
3
  """
2
4
  Parameter visibility rules and cache for HomeMatic data points.
3
5
 
@@ -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._started: bool = False
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 started(self) -> bool:
383
- """Return if the central is started."""
384
- return self._started
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
- if self._started:
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._started = True
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
- if not self._started:
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._started = False
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
- _LOGGER.debug(
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
- _LOGGER.debug(
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
- _LOGGER.warning(
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
- _LOGGER.debug(
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
- _LOGGER.debug(
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
- _LOGGER.debug(
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
- _LOGGER.warning(
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 not self._central.started:
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
- args = args[1:]
102
- interface_id: str = args[0] if len(args) > 1 else str(kwargs[_INTERFACE_ID])
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(*args, **kwargs)
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 reduce kwargs for event_callback")
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
- return async_wrapper_event_callback
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,
@@ -1,3 +1,5 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025 Daniel Perna, SukramJ
1
3
  """
2
4
  Client adapters for communicating with HomeMatic CCU and compatible backends.
3
5
 
@@ -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)
@@ -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
- error_message = error[_JsonKey.MESSAGE]
415
- message = f"POST method '{method}' failed: {error_message}"
416
- if error_message.startswith("access denied"):
417
- _LOGGER.debug(message)
418
- raise AuthFailure(message)
419
- if "internal error" in error_message:
420
- message = f"An internal error happened within your backend (Fix or ignore it): {message}"
421
- _LOGGER.debug(message)
422
- raise InternalBackendException(message)
423
- _LOGGER.debug(message)
424
- raise ClientException(message)
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
- error_message = error[_JsonKey.MESSAGE]
432
- message = f"{message}: {error_message}"
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: