aiohomematic 2025.8.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of aiohomematic might be problematic. Click here for more details.

Files changed (77) hide show
  1. aiohomematic/__init__.py +47 -0
  2. aiohomematic/async_support.py +146 -0
  3. aiohomematic/caches/__init__.py +10 -0
  4. aiohomematic/caches/dynamic.py +554 -0
  5. aiohomematic/caches/persistent.py +459 -0
  6. aiohomematic/caches/visibility.py +774 -0
  7. aiohomematic/central/__init__.py +2034 -0
  8. aiohomematic/central/decorators.py +110 -0
  9. aiohomematic/central/xml_rpc_server.py +267 -0
  10. aiohomematic/client/__init__.py +1746 -0
  11. aiohomematic/client/json_rpc.py +1193 -0
  12. aiohomematic/client/xml_rpc.py +222 -0
  13. aiohomematic/const.py +795 -0
  14. aiohomematic/context.py +8 -0
  15. aiohomematic/converter.py +82 -0
  16. aiohomematic/decorators.py +188 -0
  17. aiohomematic/exceptions.py +145 -0
  18. aiohomematic/hmcli.py +159 -0
  19. aiohomematic/model/__init__.py +137 -0
  20. aiohomematic/model/calculated/__init__.py +65 -0
  21. aiohomematic/model/calculated/climate.py +230 -0
  22. aiohomematic/model/calculated/data_point.py +319 -0
  23. aiohomematic/model/calculated/operating_voltage_level.py +311 -0
  24. aiohomematic/model/calculated/support.py +174 -0
  25. aiohomematic/model/custom/__init__.py +175 -0
  26. aiohomematic/model/custom/climate.py +1334 -0
  27. aiohomematic/model/custom/const.py +146 -0
  28. aiohomematic/model/custom/cover.py +741 -0
  29. aiohomematic/model/custom/data_point.py +318 -0
  30. aiohomematic/model/custom/definition.py +861 -0
  31. aiohomematic/model/custom/light.py +1092 -0
  32. aiohomematic/model/custom/lock.py +389 -0
  33. aiohomematic/model/custom/siren.py +268 -0
  34. aiohomematic/model/custom/support.py +40 -0
  35. aiohomematic/model/custom/switch.py +172 -0
  36. aiohomematic/model/custom/valve.py +112 -0
  37. aiohomematic/model/data_point.py +1109 -0
  38. aiohomematic/model/decorators.py +173 -0
  39. aiohomematic/model/device.py +1347 -0
  40. aiohomematic/model/event.py +210 -0
  41. aiohomematic/model/generic/__init__.py +211 -0
  42. aiohomematic/model/generic/action.py +32 -0
  43. aiohomematic/model/generic/binary_sensor.py +28 -0
  44. aiohomematic/model/generic/button.py +25 -0
  45. aiohomematic/model/generic/data_point.py +162 -0
  46. aiohomematic/model/generic/number.py +73 -0
  47. aiohomematic/model/generic/select.py +36 -0
  48. aiohomematic/model/generic/sensor.py +72 -0
  49. aiohomematic/model/generic/switch.py +52 -0
  50. aiohomematic/model/generic/text.py +27 -0
  51. aiohomematic/model/hub/__init__.py +334 -0
  52. aiohomematic/model/hub/binary_sensor.py +22 -0
  53. aiohomematic/model/hub/button.py +26 -0
  54. aiohomematic/model/hub/data_point.py +332 -0
  55. aiohomematic/model/hub/number.py +37 -0
  56. aiohomematic/model/hub/select.py +47 -0
  57. aiohomematic/model/hub/sensor.py +35 -0
  58. aiohomematic/model/hub/switch.py +42 -0
  59. aiohomematic/model/hub/text.py +28 -0
  60. aiohomematic/model/support.py +599 -0
  61. aiohomematic/model/update.py +136 -0
  62. aiohomematic/py.typed +0 -0
  63. aiohomematic/rega_scripts/fetch_all_device_data.fn +75 -0
  64. aiohomematic/rega_scripts/get_program_descriptions.fn +30 -0
  65. aiohomematic/rega_scripts/get_serial.fn +44 -0
  66. aiohomematic/rega_scripts/get_system_variable_descriptions.fn +30 -0
  67. aiohomematic/rega_scripts/set_program_state.fn +12 -0
  68. aiohomematic/rega_scripts/set_system_variable.fn +15 -0
  69. aiohomematic/support.py +482 -0
  70. aiohomematic/validator.py +65 -0
  71. aiohomematic-2025.8.6.dist-info/METADATA +69 -0
  72. aiohomematic-2025.8.6.dist-info/RECORD +77 -0
  73. aiohomematic-2025.8.6.dist-info/WHEEL +5 -0
  74. aiohomematic-2025.8.6.dist-info/licenses/LICENSE +21 -0
  75. aiohomematic-2025.8.6.dist-info/top_level.txt +2 -0
  76. aiohomematic_support/__init__.py +1 -0
  77. aiohomematic_support/client_local.py +349 -0
@@ -0,0 +1,2034 @@
1
+ """
2
+ Central unit and core orchestration for HomeMatic CCU and compatible backends.
3
+
4
+ Overview
5
+ --------
6
+ This package provides the central coordination layer for aiohomematic. It models a
7
+ HomeMatic CCU (or compatible backend such as Homegear) and orchestrates
8
+ interfaces, devices, channels, data points, events, and background jobs.
9
+
10
+ The central unit ties together the various submodules: caches, client adapters
11
+ (JSON-RPC/XML-RPC), device and data point models, and visibility/description caches.
12
+ It exposes high-level APIs to query and manipulate the CCU state while
13
+ encapsulating transport and scheduling details.
14
+
15
+ Public API (selected)
16
+ ---------------------
17
+ - CentralUnit: The main coordination class. Manages client creation/lifecycle,
18
+ connection state, device and channel discovery, data point and event handling,
19
+ sysvar/program access, cache loading/saving, and dispatching callbacks.
20
+ - CentralConfig: Configuration builder/holder for CentralUnit instances, including
21
+ connection parameters, feature toggles, and cache behavior.
22
+ - CentralConnectionState: Tracks connection issues per transport/client.
23
+
24
+ Internal helpers
25
+ ----------------
26
+ - _Scheduler: Background thread that periodically checks connection health,
27
+ refreshes data, and fetches firmware status according to configured intervals.
28
+
29
+ Quick start
30
+ -----------
31
+ Typical usage is to create a CentralConfig, build a CentralUnit, then start it.
32
+
33
+ Example (simplified):
34
+
35
+ from aiohomematic.central import CentralConfig
36
+ from aiohomematic import client as hmcl
37
+
38
+ iface_cfgs = {
39
+ hmcl.InterfaceConfig(interface=hmcl.Interface.HMIP, port=2010, enabled=True),
40
+ hmcl.InterfaceConfig(interface=hmcl.Interface.BIDCOS_RF, port=2001, enabled=True),
41
+ }
42
+
43
+ cfg = CentralConfig(
44
+ central_id="ccu-main",
45
+ default_callback_port=43439,
46
+ host="ccu.local",
47
+ interface_configs=iface_cfgs,
48
+ name="MyCCU",
49
+ password="secret",
50
+ storage_folder=".storage",
51
+ username="admin",
52
+ )
53
+
54
+ central = cfg.create_central()
55
+ central.start() # start XML-RPC server, create/init clients, load caches
56
+ # ... interact with devices / data points via central ...
57
+ central.stop()
58
+
59
+ Notes
60
+ -----
61
+ - The central module is thread-aware and uses an internal Looper to schedule async tasks.
62
+ - For advanced scenarios, see xml_rpc_server and decorators modules in this package.
63
+
64
+ """
65
+
66
+ from __future__ import annotations
67
+
68
+ import asyncio
69
+ from collections.abc import Callable, Coroutine, Mapping, Set as AbstractSet
70
+ from datetime import datetime, timedelta
71
+ from functools import partial
72
+ import logging
73
+ from logging import DEBUG
74
+ import threading
75
+ from typing import Any, Final, cast
76
+
77
+ from aiohttp import ClientSession
78
+ import voluptuous as vol
79
+
80
+ from aiohomematic import client as hmcl
81
+ from aiohomematic.async_support import Looper, loop_check
82
+ from aiohomematic.caches.dynamic import CentralDataCache, DeviceDetailsCache
83
+ from aiohomematic.caches.persistent import DeviceDescriptionCache, ParamsetDescriptionCache
84
+ from aiohomematic.caches.visibility import ParameterVisibilityCache
85
+ from aiohomematic.central import xml_rpc_server as xmlrpc
86
+ from aiohomematic.central.decorators import callback_backend_system, callback_event
87
+ from aiohomematic.client.json_rpc import JsonRpcAioHttpClient
88
+ from aiohomematic.client.xml_rpc import XmlRpcProxy
89
+ from aiohomematic.const import (
90
+ CALLBACK_TYPE,
91
+ CATEGORIES,
92
+ CONNECTION_CHECKER_INTERVAL,
93
+ DATA_POINT_EVENTS,
94
+ DATETIME_FORMAT_MILLIS,
95
+ DEFAULT_ENABLE_DEVICE_FIRMWARE_CHECK,
96
+ DEFAULT_ENABLE_PROGRAM_SCAN,
97
+ DEFAULT_ENABLE_SYSVAR_SCAN,
98
+ DEFAULT_HM_MASTER_POLL_AFTER_SEND_INTERVALS,
99
+ DEFAULT_IGNORE_CUSTOM_DEVICE_DEFINITION_MODELS,
100
+ DEFAULT_MAX_READ_WORKERS,
101
+ DEFAULT_PERIODIC_REFRESH_INTERVAL,
102
+ DEFAULT_PROGRAM_MARKERS,
103
+ DEFAULT_SYS_SCAN_INTERVAL,
104
+ DEFAULT_SYSVAR_MARKERS,
105
+ DEFAULT_TLS,
106
+ DEFAULT_UN_IGNORES,
107
+ DEFAULT_VERIFY_TLS,
108
+ DEVICE_FIRMWARE_CHECK_INTERVAL,
109
+ DEVICE_FIRMWARE_DELIVERING_CHECK_INTERVAL,
110
+ DEVICE_FIRMWARE_UPDATING_CHECK_INTERVAL,
111
+ IGNORE_FOR_UN_IGNORE_PARAMETERS,
112
+ INTERFACES_REQUIRING_PERIODIC_REFRESH,
113
+ IP_ANY_V4,
114
+ LOCAL_HOST,
115
+ PORT_ANY,
116
+ PRIMARY_CLIENT_CANDIDATE_INTERFACES,
117
+ SCHEDULER_LOOP_SLEEP,
118
+ SCHEDULER_NOT_STARTED_SLEEP,
119
+ TIMEOUT,
120
+ UN_IGNORE_WILDCARD,
121
+ BackendSystemEvent,
122
+ DataOperationResult,
123
+ DataPointCategory,
124
+ DataPointKey,
125
+ DescriptionMarker,
126
+ DeviceDescription,
127
+ DeviceFirmwareState,
128
+ EventKey,
129
+ EventType,
130
+ Interface,
131
+ InterfaceEventType,
132
+ Operations,
133
+ Parameter,
134
+ ParamsetKey,
135
+ ProxyInitState,
136
+ SystemInformation,
137
+ )
138
+ from aiohomematic.decorators import inspector
139
+ from aiohomematic.exceptions import (
140
+ AioHomematicConfigException,
141
+ AioHomematicException,
142
+ BaseHomematicException,
143
+ NoClientsException,
144
+ NoConnectionException,
145
+ )
146
+ from aiohomematic.model import create_data_points_and_events
147
+ from aiohomematic.model.custom import CustomDataPoint, create_custom_data_points
148
+ from aiohomematic.model.data_point import BaseParameterDataPoint, CallbackDataPoint
149
+ from aiohomematic.model.decorators import info_property
150
+ from aiohomematic.model.device import Channel, Device
151
+ from aiohomematic.model.event import GenericEvent
152
+ from aiohomematic.model.generic import GenericDataPoint
153
+ from aiohomematic.model.hub import (
154
+ GenericHubDataPoint,
155
+ GenericProgramDataPoint,
156
+ GenericSysvarDataPoint,
157
+ Hub,
158
+ ProgramDpType,
159
+ )
160
+ from aiohomematic.model.support import PayloadMixin
161
+ from aiohomematic.support import check_config, extract_exc_args, get_channel_no, get_device_address, get_ip_addr
162
+
163
+ __all__ = ["CentralConfig", "CentralUnit", "INTERFACE_EVENT_SCHEMA"]
164
+
165
+ _LOGGER: Final = logging.getLogger(__name__)
166
+
167
+ # {central_name, central}
168
+ CENTRAL_INSTANCES: Final[dict[str, CentralUnit]] = {}
169
+ ConnectionProblemIssuer = JsonRpcAioHttpClient | XmlRpcProxy
170
+
171
+ INTERFACE_EVENT_SCHEMA = vol.Schema(
172
+ {
173
+ vol.Required(str(EventKey.INTERFACE_ID)): str,
174
+ vol.Required(str(EventKey.TYPE)): InterfaceEventType,
175
+ vol.Required(str(EventKey.DATA)): vol.Schema(
176
+ {vol.Required(vol.Any(EventKey)): vol.Schema(vol.Any(str, int, bool))}
177
+ ),
178
+ }
179
+ )
180
+
181
+
182
+ class CentralUnit(PayloadMixin):
183
+ """Central unit that collects everything to handle communication from/to CCU/Homegear."""
184
+
185
+ def __init__(self, central_config: CentralConfig) -> None:
186
+ """Init the central unit."""
187
+ self._started: bool = False
188
+ self._clients_started: bool = False
189
+ self._device_add_semaphore: Final = asyncio.Semaphore()
190
+ self._connection_state: Final = CentralConnectionState()
191
+ self._tasks: Final[set[asyncio.Future[Any]]] = set()
192
+ # Keep the config for the central
193
+ self._config: Final = central_config
194
+ self._url: Final = self._config.create_central_url()
195
+ self._model: str | None = None
196
+ self._looper = Looper()
197
+ self._xml_rpc_server: xmlrpc.XmlRpcServer | None = None
198
+ self._json_rpc_client: JsonRpcAioHttpClient | None = None
199
+
200
+ # Caches for CCU data
201
+ self._data_cache: Final = CentralDataCache(central=self)
202
+ self._device_details: Final = DeviceDetailsCache(central=self)
203
+ self._device_descriptions: Final = DeviceDescriptionCache(central=self)
204
+ self._paramset_descriptions: Final = ParamsetDescriptionCache(central=self)
205
+ self._parameter_visibility: Final = ParameterVisibilityCache(central=self)
206
+
207
+ self._primary_client: hmcl.Client | None = None
208
+ # {interface_id, client}
209
+ self._clients: Final[dict[str, hmcl.Client]] = {}
210
+ self._data_point_key_event_subscriptions: Final[
211
+ dict[DataPointKey, list[Callable[[Any], Coroutine[Any, Any, None]]]]
212
+ ] = {}
213
+ self._data_point_path_event_subscriptions: Final[dict[str, DataPointKey]] = {}
214
+ self._sysvar_data_point_event_subscriptions: Final[dict[str, Callable]] = {}
215
+ # {device_address, device}
216
+ self._devices: Final[dict[str, Device]] = {}
217
+ # {sysvar_name, sysvar_data_point}
218
+ self._sysvar_data_points: Final[dict[str, GenericSysvarDataPoint]] = {}
219
+ # {sysvar_name, program_button}
220
+ self._program_data_points: Final[dict[str, ProgramDpType]] = {}
221
+ # Signature: (name, *args)
222
+ # e.g. DEVICES_CREATED, HUB_REFRESHED
223
+ self._backend_system_callbacks: Final[set[Callable]] = set()
224
+ # Signature: (interface_id, channel_address, parameter, value)
225
+ # Re-Fired events from CCU for parameter updates
226
+ self._backend_parameter_callbacks: Final[set[Callable]] = set()
227
+ # Signature: (event_type, event_data)
228
+ # Events like INTERFACE, KEYPRESS, ...
229
+ self._homematic_callbacks: Final[set[Callable]] = set()
230
+
231
+ CENTRAL_INSTANCES[self.name] = self
232
+ self._scheduler: Final = _Scheduler(central=self)
233
+ self._hub: Hub = Hub(central=self)
234
+ self._version: str | None = None
235
+ # store last event received datetime by interface_id
236
+ self._last_events: Final[dict[str, datetime]] = {}
237
+ self._xml_rpc_callback_ip: str = IP_ANY_V4
238
+ self._listen_ip_addr: str = IP_ANY_V4
239
+ self._listen_port: int = PORT_ANY
240
+
241
+ @property
242
+ def available(self) -> bool:
243
+ """Return the availability of the central."""
244
+ return all(client.available for client in self._clients.values())
245
+
246
+ @property
247
+ def callback_ip_addr(self) -> str:
248
+ """Return the xml rpc server callback ip address."""
249
+ return self._xml_rpc_callback_ip
250
+
251
+ @info_property
252
+ def url(self) -> str:
253
+ """Return the central url."""
254
+ return self._url
255
+
256
+ @property
257
+ def clients(self) -> tuple[hmcl.Client, ...]:
258
+ """Return all clients."""
259
+ return tuple(self._clients.values())
260
+
261
+ @property
262
+ def config(self) -> CentralConfig:
263
+ """Return central config."""
264
+ return self._config
265
+
266
+ @property
267
+ def connection_state(self) -> CentralConnectionState:
268
+ """Return the connection state."""
269
+ return self._connection_state
270
+
271
+ @property
272
+ def data_cache(self) -> CentralDataCache:
273
+ """Return data_cache cache."""
274
+ return self._data_cache
275
+
276
+ @property
277
+ def device_details(self) -> DeviceDetailsCache:
278
+ """Return device_details cache."""
279
+ return self._device_details
280
+
281
+ @property
282
+ def device_descriptions(self) -> DeviceDescriptionCache:
283
+ """Return device_descriptions cache."""
284
+ return self._device_descriptions
285
+
286
+ @property
287
+ def devices(self) -> tuple[Device, ...]:
288
+ """Return all devices."""
289
+ return tuple(self._devices.values())
290
+
291
+ @property
292
+ def _has_active_threads(self) -> bool:
293
+ """Return if active sub threads are alive."""
294
+ if self._scheduler.is_alive():
295
+ return True
296
+ return bool(
297
+ self._xml_rpc_server and self._xml_rpc_server.no_central_assigned and self._xml_rpc_server.is_alive()
298
+ )
299
+
300
+ @property
301
+ def interface_ids(self) -> tuple[str, ...]:
302
+ """Return all associated interface ids."""
303
+ return tuple(self._clients)
304
+
305
+ @property
306
+ def interfaces(self) -> tuple[Interface, ...]:
307
+ """Return all associated interfaces."""
308
+ return tuple(client.interface for client in self._clients.values())
309
+
310
+ @property
311
+ def is_alive(self) -> bool:
312
+ """Return if XmlRPC-Server is alive."""
313
+ return all(client.is_callback_alive() for client in self._clients.values())
314
+
315
+ @property
316
+ def json_rpc_client(self) -> JsonRpcAioHttpClient:
317
+ """Return the json rpc client."""
318
+ if not self._json_rpc_client:
319
+ self._json_rpc_client = self._config.create_json_rpc_client(central=self)
320
+ return self._json_rpc_client
321
+
322
+ @property
323
+ def paramset_descriptions(self) -> ParamsetDescriptionCache:
324
+ """Return paramset_descriptions cache."""
325
+ return self._paramset_descriptions
326
+
327
+ @property
328
+ def parameter_visibility(self) -> ParameterVisibilityCache:
329
+ """Return parameter_visibility cache."""
330
+ return self._parameter_visibility
331
+
332
+ @property
333
+ def poll_clients(self) -> tuple[hmcl.Client, ...]:
334
+ """Return clients that need to poll data."""
335
+ return tuple(client for client in self._clients.values() if not client.supports_push_updates)
336
+
337
+ @property
338
+ def primary_client(self) -> hmcl.Client | None:
339
+ """Return the primary client of the backend."""
340
+ if self._primary_client is not None:
341
+ return self._primary_client
342
+ if client := self._get_primary_client():
343
+ self._primary_client = client
344
+ return self._primary_client
345
+
346
+ @property
347
+ def listen_ip_addr(self) -> str:
348
+ """Return the xml rpc server listening ip address."""
349
+ return self._listen_ip_addr
350
+
351
+ @property
352
+ def listen_port(self) -> int:
353
+ """Return the xml rpc listening server port."""
354
+ return self._listen_port
355
+
356
+ @property
357
+ def looper(self) -> Looper:
358
+ """Return the loop support."""
359
+ return self._looper
360
+
361
+ @info_property
362
+ def model(self) -> str | None:
363
+ """Return the model of the backend."""
364
+ if not self._model and (client := self.primary_client):
365
+ self._model = client.model
366
+ return self._model
367
+
368
+ @info_property
369
+ def name(self) -> str:
370
+ """Return the name of the backend."""
371
+ return self._config.name
372
+
373
+ @property
374
+ def program_data_points(self) -> tuple[GenericProgramDataPoint, ...]:
375
+ """Return the program data points."""
376
+ return tuple(
377
+ [x.button for x in self._program_data_points.values()]
378
+ + [x.switch for x in self._program_data_points.values()]
379
+ )
380
+
381
+ @property
382
+ def started(self) -> bool:
383
+ """Return if the central is started."""
384
+ return self._started
385
+
386
+ @property
387
+ def supports_ping_pong(self) -> bool:
388
+ """Return the backend supports ping pong."""
389
+ if primary_client := self.primary_client:
390
+ return primary_client.supports_ping_pong
391
+ return False
392
+
393
+ @property
394
+ def system_information(self) -> SystemInformation:
395
+ """Return the system_information of the backend."""
396
+ if client := self.primary_client:
397
+ return client.system_information
398
+ return SystemInformation()
399
+
400
+ @property
401
+ def sysvar_data_points(self) -> tuple[GenericSysvarDataPoint, ...]:
402
+ """Return the sysvar data points."""
403
+ return tuple(self._sysvar_data_points.values())
404
+
405
+ @info_property
406
+ def version(self) -> str | None:
407
+ """Return the version of the backend."""
408
+ if self._version is None:
409
+ versions = [client.version for client in self._clients.values() if client.version]
410
+ self._version = max(versions) if versions else None
411
+ return self._version
412
+
413
+ def add_sysvar_data_point(self, sysvar_data_point: GenericSysvarDataPoint) -> None:
414
+ """Add new program button."""
415
+ if (vid := sysvar_data_point.vid) is not None:
416
+ self._sysvar_data_points[vid] = sysvar_data_point
417
+ if sysvar_data_point.state_path not in self._sysvar_data_point_event_subscriptions:
418
+ self._sysvar_data_point_event_subscriptions[sysvar_data_point.state_path] = sysvar_data_point.event
419
+
420
+ def remove_sysvar_data_point(self, vid: str) -> None:
421
+ """Remove a sysvar data_point."""
422
+ if (sysvar_dp := self.get_sysvar_data_point(vid=vid)) is not None:
423
+ sysvar_dp.fire_device_removed_callback()
424
+ del self._sysvar_data_points[vid]
425
+ if sysvar_dp.state_path in self._sysvar_data_point_event_subscriptions:
426
+ del self._sysvar_data_point_event_subscriptions[sysvar_dp.state_path]
427
+
428
+ def add_program_data_point(self, program_dp: ProgramDpType) -> None:
429
+ """Add new program button."""
430
+ self._program_data_points[program_dp.pid] = program_dp
431
+
432
+ def remove_program_button(self, pid: str) -> None:
433
+ """Remove a program button."""
434
+ if (program_dp := self.get_program_data_point(pid=pid)) is not None:
435
+ program_dp.button.fire_device_removed_callback()
436
+ program_dp.switch.fire_device_removed_callback()
437
+ del self._program_data_points[pid]
438
+
439
+ def identify_channel(self, text: str) -> Channel | None:
440
+ """Identify channel within a text."""
441
+ for device in self._devices.values():
442
+ if channel := device.identify_channel(text=text):
443
+ return channel
444
+ return None
445
+
446
+ async def save_caches(
447
+ self, save_device_descriptions: bool = False, save_paramset_descriptions: bool = False
448
+ ) -> None:
449
+ """Save persistent caches."""
450
+ if save_device_descriptions:
451
+ await self._device_descriptions.save()
452
+ if save_paramset_descriptions:
453
+ await self._paramset_descriptions.save()
454
+
455
+ async def start(self) -> None:
456
+ """Start processing of the central unit."""
457
+
458
+ if self._started:
459
+ _LOGGER.debug("START: Central %s already started", self.name)
460
+ return
461
+ if self._config.enabled_interface_configs and (
462
+ ip_addr := await self._identify_ip_addr(port=self._config.connection_check_port)
463
+ ):
464
+ self._xml_rpc_callback_ip = ip_addr
465
+ self._listen_ip_addr = self._config.listen_ip_addr if self._config.listen_ip_addr else ip_addr
466
+
467
+ listen_port: int = (
468
+ self._config.listen_port
469
+ if self._config.listen_port
470
+ else self._config.callback_port or self._config.default_callback_port
471
+ )
472
+ try:
473
+ if (
474
+ xml_rpc_server := xmlrpc.create_xml_rpc_server(ip_addr=self._listen_ip_addr, port=listen_port)
475
+ if self._config.enable_server
476
+ else None
477
+ ):
478
+ self._xml_rpc_server = xml_rpc_server
479
+ self._listen_port = xml_rpc_server.listen_port
480
+ self._xml_rpc_server.add_central(self)
481
+ except OSError as oserr:
482
+ raise AioHomematicException(
483
+ f"START: Failed to start central unit {self.name}: {extract_exc_args(exc=oserr)}"
484
+ ) from oserr
485
+
486
+ if self._config.start_direct:
487
+ if await self._create_clients():
488
+ for client in self._clients.values():
489
+ await self._refresh_device_descriptions(client=client)
490
+ else:
491
+ self._clients_started = await self._start_clients()
492
+ if self._config.enable_server:
493
+ self._start_scheduler()
494
+
495
+ self._started = True
496
+
497
+ async def stop(self) -> None:
498
+ """Stop processing of the central unit."""
499
+ if not self._started:
500
+ _LOGGER.debug("STOP: Central %s not started", self.name)
501
+ return
502
+ await self.save_caches(save_device_descriptions=True, save_paramset_descriptions=True)
503
+ self._stop_scheduler()
504
+ await self._stop_clients()
505
+ if self._json_rpc_client and self._json_rpc_client.is_activated:
506
+ await self._json_rpc_client.logout()
507
+ await self._json_rpc_client.stop()
508
+
509
+ if self._xml_rpc_server:
510
+ # un-register this instance from XmlRPC-Server
511
+ self._xml_rpc_server.remove_central(central=self)
512
+ # un-register and stop XmlRPC-Server, if possible
513
+ if self._xml_rpc_server.no_central_assigned:
514
+ self._xml_rpc_server.stop()
515
+ _LOGGER.debug("STOP: XmlRPC-Server stopped")
516
+ else:
517
+ _LOGGER.debug("STOP: shared XmlRPC-Server NOT stopped. There is still another central instance registered")
518
+
519
+ _LOGGER.debug("STOP: Removing instance")
520
+ if self.name in CENTRAL_INSTANCES:
521
+ del CENTRAL_INSTANCES[self.name]
522
+
523
+ # cancel outstanding tasks to speed up teardown
524
+ self.looper.cancel_tasks()
525
+ # wait until tasks are finished
526
+ await self.looper.block_till_done()
527
+
528
+ # Wait briefly for any auxiliary threads to finish without blocking forever
529
+ max_wait_seconds = 5.0
530
+ interval = 0.05
531
+ waited = 0.0
532
+ while self._has_active_threads and waited < max_wait_seconds:
533
+ await asyncio.sleep(interval)
534
+ waited += interval
535
+ self._started = False
536
+
537
+ async def restart_clients(self) -> None:
538
+ """Restart clients."""
539
+ await self._stop_clients()
540
+ if await self._start_clients():
541
+ _LOGGER.info("RESTART_CLIENTS: Central %s restarted clients", self.name)
542
+
543
+ @inspector(re_raise=False)
544
+ async def refresh_firmware_data(self, device_address: str | None = None) -> None:
545
+ """Refresh device firmware data."""
546
+ if device_address and (device := self.get_device(address=device_address)) is not None and device.is_updatable:
547
+ await self._refresh_device_descriptions(client=device.client, device_address=device_address)
548
+ device.refresh_firmware_data()
549
+ else:
550
+ for client in self._clients.values():
551
+ await self._refresh_device_descriptions(client=client)
552
+ for device in self._devices.values():
553
+ if device.is_updatable:
554
+ device.refresh_firmware_data()
555
+
556
+ @inspector(re_raise=False)
557
+ async def refresh_firmware_data_by_state(self, device_firmware_states: tuple[DeviceFirmwareState, ...]) -> None:
558
+ """Refresh device firmware data for processing devices."""
559
+ for device in [
560
+ device_in_state
561
+ for device_in_state in self._devices.values()
562
+ if device_in_state.firmware_update_state in device_firmware_states
563
+ ]:
564
+ await self.refresh_firmware_data(device_address=device.address)
565
+
566
+ async def _refresh_device_descriptions(self, client: hmcl.Client, device_address: str | None = None) -> None:
567
+ """Refresh device descriptions."""
568
+ device_descriptions: tuple[DeviceDescription, ...] | None = None
569
+ if (
570
+ device_address
571
+ and (device_description := await client.get_device_description(device_address=device_address)) is not None
572
+ ):
573
+ device_descriptions = (device_description,)
574
+ else:
575
+ device_descriptions = await client.list_devices()
576
+
577
+ if device_descriptions:
578
+ await self._add_new_devices(
579
+ interface_id=client.interface_id,
580
+ device_descriptions=device_descriptions,
581
+ )
582
+
583
+ async def _start_clients(self) -> bool:
584
+ """Start clients ."""
585
+ if not await self._create_clients():
586
+ return False
587
+ await self._load_caches()
588
+ if new_device_addresses := self._check_for_new_device_addresses():
589
+ await self._create_devices(new_device_addresses=new_device_addresses)
590
+ await self._init_hub()
591
+ await self._init_clients()
592
+ # Proactively fetch device descriptions if none were created yet to avoid slow startup
593
+ if not self._devices:
594
+ for client in self._clients.values():
595
+ await self._refresh_device_descriptions(client=client)
596
+ return True
597
+
598
+ async def _stop_clients(self) -> None:
599
+ """Stop clients."""
600
+ await self._de_init_clients()
601
+ for client in self._clients.values():
602
+ _LOGGER.debug("STOP_CLIENTS: Stopping %s", client.interface_id)
603
+ await client.stop()
604
+ _LOGGER.debug("STOP_CLIENTS: Clearing existing clients.")
605
+ self._clients.clear()
606
+ self._clients_started = False
607
+
608
+ async def _create_clients(self) -> bool:
609
+ """Create clients for the central unit. Start connection checker afterwards."""
610
+ if len(self._clients) > 0:
611
+ _LOGGER.warning(
612
+ "CREATE_CLIENTS: Clients for %s are already created",
613
+ self.name,
614
+ )
615
+ return False
616
+ if len(self._config.enabled_interface_configs) == 0:
617
+ _LOGGER.warning(
618
+ "CREATE_CLIENTS failed: No Interfaces for %s defined",
619
+ self.name,
620
+ )
621
+ return False
622
+
623
+ # create primary clients
624
+ for interface_config in self._config.enabled_interface_configs:
625
+ if interface_config.interface in PRIMARY_CLIENT_CANDIDATE_INTERFACES:
626
+ await self._create_client(interface_config=interface_config)
627
+
628
+ # create secondary clients
629
+ for interface_config in self._config.enabled_interface_configs:
630
+ if interface_config.interface not in PRIMARY_CLIENT_CANDIDATE_INTERFACES:
631
+ if (
632
+ self.primary_client is not None
633
+ and interface_config.interface not in self.primary_client.system_information.available_interfaces
634
+ ):
635
+ _LOGGER.warning(
636
+ "CREATE_CLIENTS failed: Interface: %s is not available for backend %s",
637
+ interface_config.interface,
638
+ self.name,
639
+ )
640
+ interface_config.disable()
641
+ continue
642
+ await self._create_client(interface_config=interface_config)
643
+
644
+ if not self.all_clients_active:
645
+ _LOGGER.warning(
646
+ "CREATE_CLIENTS failed: Created %i of %i clients",
647
+ len(self._clients),
648
+ len(self._config.enabled_interface_configs),
649
+ )
650
+ return False
651
+
652
+ if self.primary_client is None:
653
+ _LOGGER.warning("CREATE_CLIENTS failed: No primary client identified for %s", self.name)
654
+ return True
655
+
656
+ _LOGGER.debug("CREATE_CLIENTS successful for %s", self.name)
657
+ return True
658
+
659
+ async def _create_client(self, interface_config: hmcl.InterfaceConfig) -> bool:
660
+ """Create a client."""
661
+ try:
662
+ if client := await hmcl.create_client(
663
+ central=self,
664
+ interface_config=interface_config,
665
+ ):
666
+ _LOGGER.debug(
667
+ "CREATE_CLIENT: Adding client %s to %s",
668
+ client.interface_id,
669
+ self.name,
670
+ )
671
+ self._clients[client.interface_id] = client
672
+ return True
673
+ except BaseHomematicException as bhexc:
674
+ self.fire_interface_event(
675
+ interface_id=interface_config.interface_id,
676
+ interface_event_type=InterfaceEventType.PROXY,
677
+ data={EventKey.AVAILABLE: False},
678
+ )
679
+
680
+ _LOGGER.warning(
681
+ "CREATE_CLIENT failed: No connection to interface %s [%s]",
682
+ interface_config.interface_id,
683
+ extract_exc_args(exc=bhexc),
684
+ )
685
+ return False
686
+
687
+ async def _init_clients(self) -> None:
688
+ """Init clients of control unit, and start connection checker."""
689
+ for client in self._clients.copy().values():
690
+ if client.interface not in self.system_information.available_interfaces:
691
+ _LOGGER.debug(
692
+ "INIT_CLIENTS failed: Interface: %s is not available for backend %s",
693
+ client.interface,
694
+ self.name,
695
+ )
696
+ del self._clients[client.interface_id]
697
+ continue
698
+ if await client.initialize_proxy() == ProxyInitState.INIT_SUCCESS:
699
+ _LOGGER.debug("INIT_CLIENTS: client %s initialized for %s", client.interface_id, self.name)
700
+
701
+ async def _de_init_clients(self) -> None:
702
+ """De-init clients."""
703
+ for name, client in self._clients.items():
704
+ if await client.deinitialize_proxy():
705
+ _LOGGER.debug("DE_INIT_CLIENTS: Proxy de-initialized: %s", name)
706
+
707
+ async def _init_hub(self) -> None:
708
+ """Init the hub."""
709
+ await self._hub.fetch_program_data(scheduled=True)
710
+ await self._hub.fetch_sysvar_data(scheduled=True)
711
+
712
+ @loop_check
713
+ def fire_interface_event(
714
+ self,
715
+ interface_id: str,
716
+ interface_event_type: InterfaceEventType,
717
+ data: dict[str, Any],
718
+ ) -> None:
719
+ """Fire an event about the interface status."""
720
+ data = data or {}
721
+ event_data: dict[str, Any] = {
722
+ EventKey.INTERFACE_ID: interface_id,
723
+ EventKey.TYPE: interface_event_type,
724
+ EventKey.DATA: data,
725
+ }
726
+
727
+ self.fire_homematic_callback(
728
+ event_type=EventType.INTERFACE,
729
+ event_data=cast(dict[EventKey, Any], INTERFACE_EVENT_SCHEMA(event_data)),
730
+ )
731
+
732
+ async def _identify_ip_addr(self, port: int) -> str:
733
+ ip_addr: str | None = None
734
+ while ip_addr is None:
735
+ try:
736
+ ip_addr = await self.looper.async_add_executor_job(
737
+ get_ip_addr, self._config.host, port, name="get_ip_addr"
738
+ )
739
+ except AioHomematicException:
740
+ ip_addr = LOCAL_HOST
741
+ if ip_addr is None:
742
+ _LOGGER.warning("GET_IP_ADDR: Waiting for %i s,", CONNECTION_CHECKER_INTERVAL)
743
+ await asyncio.sleep(TIMEOUT / 10)
744
+ return ip_addr
745
+
746
+ def _start_scheduler(self) -> None:
747
+ """Start the scheduler."""
748
+ _LOGGER.debug(
749
+ "START_SCHEDULER: Starting scheduler for %s",
750
+ self.name,
751
+ )
752
+ self._scheduler.start()
753
+
754
+ def _stop_scheduler(self) -> None:
755
+ """Start the connection checker."""
756
+ self._scheduler.stop()
757
+ _LOGGER.debug(
758
+ "STOP_SCHEDULER: Stopped scheduler for %s",
759
+ self.name,
760
+ )
761
+
762
+ async def validate_config_and_get_system_information(self) -> SystemInformation:
763
+ """Validate the central configuration."""
764
+ if len(self._config.enabled_interface_configs) == 0:
765
+ raise NoClientsException("validate_config: No clients defined.")
766
+
767
+ system_information = SystemInformation()
768
+ for interface_config in self._config.enabled_interface_configs:
769
+ try:
770
+ client = await hmcl.create_client(central=self, interface_config=interface_config)
771
+ except BaseHomematicException as bhexc:
772
+ _LOGGER.error(
773
+ "VALIDATE_CONFIG_AND_GET_SYSTEM_INFORMATION failed for client %s: %s",
774
+ interface_config.interface,
775
+ extract_exc_args(exc=bhexc),
776
+ )
777
+ raise
778
+ if client.interface in PRIMARY_CLIENT_CANDIDATE_INTERFACES and not system_information.serial:
779
+ system_information = client.system_information
780
+ return system_information
781
+
782
+ def get_client(self, interface_id: str) -> hmcl.Client:
783
+ """Return a client by interface_id."""
784
+ if not self.has_client(interface_id=interface_id):
785
+ raise AioHomematicException(f"get_client: interface_id {interface_id} does not exist on {self.name}")
786
+ return self._clients[interface_id]
787
+
788
+ def get_channel(self, channel_address: str) -> Channel | None:
789
+ """Return homematic channel."""
790
+ if device := self.get_device(address=channel_address):
791
+ return device.get_channel(channel_address=channel_address)
792
+ return None
793
+
794
+ def get_device(self, address: str) -> Device | None:
795
+ """Return homematic device."""
796
+ d_address = get_device_address(address=address)
797
+ return self._devices.get(d_address)
798
+
799
+ def get_data_point_by_custom_id(self, custom_id: str) -> CallbackDataPoint | None:
800
+ """Return homematic data_point by custom_id."""
801
+ for dp in self.get_data_points(registered=True):
802
+ if dp.custom_id == custom_id:
803
+ return dp
804
+ return None
805
+
806
+ def get_data_points(
807
+ self,
808
+ category: DataPointCategory | None = None,
809
+ interface: Interface | None = None,
810
+ exclude_no_create: bool = True,
811
+ registered: bool | None = None,
812
+ ) -> tuple[CallbackDataPoint, ...]:
813
+ """Return all externally registered data points."""
814
+ all_data_points: list[CallbackDataPoint] = []
815
+ for device in self._devices.values():
816
+ if interface and interface != device.interface:
817
+ continue
818
+ all_data_points.extend(
819
+ device.get_data_points(category=category, exclude_no_create=exclude_no_create, registered=registered)
820
+ )
821
+ return tuple(all_data_points)
822
+
823
+ def get_readable_generic_data_points(
824
+ self, paramset_key: ParamsetKey | None = None, interface: Interface | None = None
825
+ ) -> tuple[GenericDataPoint, ...]:
826
+ """Return the readable generic data points."""
827
+ return tuple(
828
+ ge
829
+ for ge in self.get_data_points(interface=interface)
830
+ if (
831
+ isinstance(ge, GenericDataPoint)
832
+ and ge.is_readable
833
+ and ((paramset_key and ge.paramset_key == paramset_key) or paramset_key is None)
834
+ )
835
+ )
836
+
837
+ def _get_primary_client(self) -> hmcl.Client | None:
838
+ """Return the client by interface_id or the first with a virtual remote."""
839
+ client: hmcl.Client | None = None
840
+ for client in self._clients.values():
841
+ if client.interface in PRIMARY_CLIENT_CANDIDATE_INTERFACES and client.available:
842
+ return client
843
+ return client
844
+
845
+ def get_hub_data_points(
846
+ self, category: DataPointCategory | None = None, registered: bool | None = None
847
+ ) -> tuple[GenericHubDataPoint, ...]:
848
+ """Return the program data points."""
849
+ return tuple(
850
+ he
851
+ for he in (self.program_data_points + self.sysvar_data_points)
852
+ if (category is None or he.category == category) and (registered is None or he.is_registered == registered)
853
+ )
854
+
855
+ def get_events(self, event_type: EventType, registered: bool | None = None) -> tuple[tuple[GenericEvent, ...], ...]:
856
+ """Return all channel event data points."""
857
+ hm_channel_events: list[tuple[GenericEvent, ...]] = []
858
+ for device in self.devices:
859
+ for channel_events in device.get_events(event_type=event_type).values():
860
+ if registered is None or (channel_events[0].is_registered == registered):
861
+ hm_channel_events.append(channel_events)
862
+ continue
863
+ return tuple(hm_channel_events)
864
+
865
+ def get_virtual_remotes(self) -> tuple[Device, ...]:
866
+ """Get the virtual remote for the Client."""
867
+ return tuple(
868
+ cl.get_virtual_remote() # type: ignore[misc]
869
+ for cl in self._clients.values()
870
+ if cl.get_virtual_remote() is not None
871
+ )
872
+
873
+ def has_client(self, interface_id: str) -> bool:
874
+ """Check if client exists in central."""
875
+ return interface_id in self._clients
876
+
877
+ @property
878
+ def all_clients_active(self) -> bool:
879
+ """Check if all configured clients exists in central."""
880
+ count_client = len(self._clients)
881
+ return count_client > 0 and count_client == len(self._config.enabled_interface_configs)
882
+
883
+ @property
884
+ def has_clients(self) -> bool:
885
+ """Check if clients exists in central."""
886
+ return len(self._clients) > 0
887
+
888
+ async def _load_caches(self) -> bool:
889
+ """Load files to caches."""
890
+ if DataOperationResult.LOAD_FAIL in (
891
+ await self._device_descriptions.load(),
892
+ await self._paramset_descriptions.load(),
893
+ ):
894
+ _LOGGER.warning("LOAD_CACHES failed: Unable to load caches for %s. Clearing files", self.name)
895
+ await self.clear_caches()
896
+ return False
897
+ await self._device_details.load()
898
+ await self._data_cache.load()
899
+ return True
900
+
901
+ async def _create_devices(self, new_device_addresses: Mapping[str, set[str]]) -> None:
902
+ """Trigger creation of the objects that expose the functionality."""
903
+ if not self._clients:
904
+ raise AioHomematicException(
905
+ f"CREATE_DEVICES failed: No clients initialized. Not starting central {self.name}."
906
+ )
907
+ _LOGGER.debug("CREATE_DEVICES: Starting to create devices for %s", self.name)
908
+
909
+ new_devices = set[Device]()
910
+
911
+ for interface_id, device_addresses in new_device_addresses.items():
912
+ for device_address in device_addresses:
913
+ # Do we check for duplicates here? For now, we do.
914
+ if device_address in self._devices:
915
+ continue
916
+ device: Device | None = None
917
+ try:
918
+ device = Device(
919
+ central=self,
920
+ interface_id=interface_id,
921
+ device_address=device_address,
922
+ )
923
+ except Exception as exc: # pragma: no cover
924
+ _LOGGER.error(
925
+ "CREATE_DEVICES failed: %s [%s] Unable to create device: %s, %s",
926
+ type(exc).__name__,
927
+ extract_exc_args(exc=exc),
928
+ interface_id,
929
+ device_address,
930
+ )
931
+ try:
932
+ if device:
933
+ create_data_points_and_events(device=device)
934
+ create_custom_data_points(device=device)
935
+ await device.load_value_cache()
936
+ new_devices.add(device)
937
+ self._devices[device_address] = device
938
+ except Exception as exc: # pragma: no cover
939
+ _LOGGER.error(
940
+ "CREATE_DEVICES failed: %s [%s] Unable to create data points: %s, %s",
941
+ type(exc).__name__,
942
+ extract_exc_args(exc=exc),
943
+ interface_id,
944
+ device_address,
945
+ )
946
+ _LOGGER.debug("CREATE_DEVICES: Finished creating devices for %s", self.name)
947
+
948
+ if new_devices:
949
+ new_dps = _get_new_data_points(new_devices=new_devices)
950
+ new_channel_events = _get_new_channel_events(new_devices=new_devices)
951
+ self.fire_backend_system_callback(
952
+ system_event=BackendSystemEvent.DEVICES_CREATED,
953
+ new_data_points=new_dps,
954
+ new_channel_events=new_channel_events,
955
+ )
956
+
957
+ async def delete_device(self, interface_id: str, device_address: str) -> None:
958
+ """Delete devices from central."""
959
+ _LOGGER.debug(
960
+ "DELETE_DEVICE: interface_id = %s, device_address = %s",
961
+ interface_id,
962
+ device_address,
963
+ )
964
+
965
+ if (device := self._devices.get(device_address)) is None:
966
+ return
967
+
968
+ await self.delete_devices(interface_id=interface_id, addresses=[device_address, *list(device.channels.keys())])
969
+
970
+ @callback_backend_system(system_event=BackendSystemEvent.DELETE_DEVICES)
971
+ async def delete_devices(self, interface_id: str, addresses: tuple[str, ...]) -> None:
972
+ """Delete devices from central."""
973
+ _LOGGER.debug(
974
+ "DELETE_DEVICES: interface_id = %s, addresses = %s",
975
+ interface_id,
976
+ str(addresses),
977
+ )
978
+ for address in addresses:
979
+ if device := self._devices.get(address):
980
+ self.remove_device(device=device)
981
+ await self.save_caches()
982
+
983
+ @callback_backend_system(system_event=BackendSystemEvent.NEW_DEVICES)
984
+ async def add_new_devices(self, interface_id: str, device_descriptions: tuple[DeviceDescription, ...]) -> None:
985
+ """Add new devices to central unit."""
986
+ await self._add_new_devices(interface_id=interface_id, device_descriptions=device_descriptions)
987
+
988
+ @inspector(measure_performance=True)
989
+ async def _add_new_devices(self, interface_id: str, device_descriptions: tuple[DeviceDescription, ...]) -> None:
990
+ """Add new devices to central unit."""
991
+ if not device_descriptions:
992
+ _LOGGER.debug(
993
+ "ADD_NEW_DEVICES: Nothing to add for interface_id %s",
994
+ interface_id,
995
+ )
996
+ return
997
+
998
+ _LOGGER.debug(
999
+ "ADD_NEW_DEVICES: interface_id = %s, device_descriptions = %s",
1000
+ interface_id,
1001
+ len(device_descriptions),
1002
+ )
1003
+
1004
+ if interface_id not in self._clients:
1005
+ _LOGGER.warning(
1006
+ "ADD_NEW_DEVICES failed: Missing client for interface_id %s",
1007
+ interface_id,
1008
+ )
1009
+ return
1010
+
1011
+ async with self._device_add_semaphore:
1012
+ # We need this to avoid adding duplicates.
1013
+ known_addresses = tuple(
1014
+ dev_desc["ADDRESS"]
1015
+ for dev_desc in self._device_descriptions.get_raw_device_descriptions(interface_id=interface_id)
1016
+ )
1017
+ client = self._clients[interface_id]
1018
+ save_paramset_descriptions = False
1019
+ save_device_descriptions = False
1020
+ for dev_desc in device_descriptions:
1021
+ try:
1022
+ self._device_descriptions.add_device(interface_id=interface_id, device_description=dev_desc)
1023
+ save_device_descriptions = True
1024
+ if dev_desc["ADDRESS"] not in known_addresses:
1025
+ await client.fetch_paramset_descriptions(device_description=dev_desc)
1026
+ save_paramset_descriptions = True
1027
+ except Exception as exc: # pragma: no cover
1028
+ save_device_descriptions = False
1029
+ save_paramset_descriptions = False
1030
+ _LOGGER.error(
1031
+ "ADD_NEW_DEVICES failed: %s [%s]",
1032
+ type(exc).__name__,
1033
+ extract_exc_args(exc=exc),
1034
+ )
1035
+
1036
+ await self.save_caches(
1037
+ save_device_descriptions=save_device_descriptions,
1038
+ save_paramset_descriptions=save_paramset_descriptions,
1039
+ )
1040
+ if new_device_addresses := self._check_for_new_device_addresses():
1041
+ await self._device_details.load()
1042
+ await self._data_cache.load()
1043
+ await self._create_devices(new_device_addresses=new_device_addresses)
1044
+
1045
+ def _check_for_new_device_addresses(self) -> Mapping[str, set[str]]:
1046
+ """Check if there are new devices, that needs to be created."""
1047
+ new_device_addresses: dict[str, set[str]] = {}
1048
+ for interface_id in self.interface_ids:
1049
+ if not self._paramset_descriptions.has_interface_id(interface_id=interface_id):
1050
+ _LOGGER.debug(
1051
+ "CHECK_FOR_NEW_DEVICE_ADDRESSES: Skipping interface %s, missing paramsets",
1052
+ interface_id,
1053
+ )
1054
+ continue
1055
+
1056
+ if interface_id not in new_device_addresses:
1057
+ new_device_addresses[interface_id] = set()
1058
+
1059
+ for device_address in self._device_descriptions.get_addresses(interface_id=interface_id):
1060
+ if device_address not in self._devices:
1061
+ new_device_addresses[interface_id].add(device_address)
1062
+
1063
+ if not new_device_addresses[interface_id]:
1064
+ del new_device_addresses[interface_id]
1065
+
1066
+ if _LOGGER.isEnabledFor(level=DEBUG):
1067
+ count: int = 0
1068
+ for item in new_device_addresses.values():
1069
+ count += len(item)
1070
+
1071
+ _LOGGER.debug(
1072
+ "CHECK_FOR_NEW_DEVICE_ADDRESSES: %s: %i.",
1073
+ "Found new device addresses" if new_device_addresses else "Did not find any new device addresses",
1074
+ count,
1075
+ )
1076
+
1077
+ return new_device_addresses
1078
+
1079
+ @callback_event
1080
+ async def data_point_event(self, interface_id: str, channel_address: str, parameter: str, value: Any) -> None:
1081
+ """If a device emits some sort event, we will handle it here."""
1082
+ _LOGGER.debug(
1083
+ "EVENT: interface_id = %s, channel_address = %s, parameter = %s, value = %s",
1084
+ interface_id,
1085
+ channel_address,
1086
+ parameter,
1087
+ str(value),
1088
+ )
1089
+ if not self.has_client(interface_id=interface_id):
1090
+ return
1091
+
1092
+ self.set_last_event_dt(interface_id=interface_id)
1093
+ # No need to check the response of a XmlRPC-PING
1094
+ if parameter == Parameter.PONG:
1095
+ if "#" in value:
1096
+ v_interface_id, v_timestamp = value.split("#")
1097
+ if (
1098
+ v_interface_id == interface_id
1099
+ and (client := self.get_client(interface_id=interface_id))
1100
+ and client.supports_ping_pong
1101
+ ):
1102
+ client.ping_pong_cache.handle_received_pong(
1103
+ pong_ts=datetime.strptime(v_timestamp, DATETIME_FORMAT_MILLIS)
1104
+ )
1105
+ return
1106
+
1107
+ dpk = DataPointKey(
1108
+ interface_id=interface_id,
1109
+ channel_address=channel_address,
1110
+ paramset_key=ParamsetKey.VALUES,
1111
+ parameter=parameter,
1112
+ )
1113
+
1114
+ if dpk in self._data_point_key_event_subscriptions:
1115
+ try:
1116
+ for callback_handler in self._data_point_key_event_subscriptions[dpk]:
1117
+ if callable(callback_handler):
1118
+ await callback_handler(value)
1119
+ except RuntimeError as rterr: # pragma: no cover
1120
+ _LOGGER.debug(
1121
+ "EVENT: RuntimeError [%s]. Failed to call callback for: %s, %s, %s",
1122
+ extract_exc_args(exc=rterr),
1123
+ interface_id,
1124
+ channel_address,
1125
+ parameter,
1126
+ )
1127
+ except Exception as exc: # pragma: no cover
1128
+ _LOGGER.warning(
1129
+ "EVENT failed: Unable to call callback for: %s, %s, %s, %s",
1130
+ interface_id,
1131
+ channel_address,
1132
+ parameter,
1133
+ extract_exc_args(exc=exc),
1134
+ )
1135
+
1136
+ def data_point_path_event(self, state_path: str, value: str) -> None:
1137
+ """If a device emits some sort event, we will handle it here."""
1138
+ _LOGGER.debug(
1139
+ "DATA_POINT_PATH_EVENT: topic = %s, payload = %s",
1140
+ state_path,
1141
+ value,
1142
+ )
1143
+
1144
+ if (dpk := self._data_point_path_event_subscriptions.get(state_path)) is not None:
1145
+ self._looper.create_task(
1146
+ self.data_point_event(
1147
+ interface_id=dpk.interface_id,
1148
+ channel_address=dpk.channel_address,
1149
+ parameter=dpk.parameter,
1150
+ value=value,
1151
+ ),
1152
+ name=f"device-data-point-event-{dpk.interface_id}-{dpk.channel_address}-{dpk.parameter}",
1153
+ )
1154
+
1155
+ def sysvar_data_point_path_event(self, state_path: str, value: str) -> None:
1156
+ """If a device emits some sort event, we will handle it here."""
1157
+ _LOGGER.debug(
1158
+ "SYSVAR_DATA_POINT_PATH_EVENT: topic = %s, payload = %s",
1159
+ state_path,
1160
+ value,
1161
+ )
1162
+
1163
+ if state_path in self._sysvar_data_point_event_subscriptions:
1164
+ try:
1165
+ callback_handler = self._sysvar_data_point_event_subscriptions[state_path]
1166
+ if callable(callback_handler):
1167
+ self._looper.create_task(callback_handler(value), name=f"sysvar-data-point-event-{state_path}")
1168
+ except RuntimeError as rterr: # pragma: no cover
1169
+ _LOGGER.debug(
1170
+ "EVENT: RuntimeError [%s]. Failed to call callback for: %s",
1171
+ extract_exc_args(exc=rterr),
1172
+ state_path,
1173
+ )
1174
+ except Exception as exc: # pragma: no cover
1175
+ _LOGGER.warning(
1176
+ "EVENT failed: Unable to call callback for: %s, %s",
1177
+ state_path,
1178
+ extract_exc_args(exc=exc),
1179
+ )
1180
+
1181
+ @callback_backend_system(system_event=BackendSystemEvent.LIST_DEVICES)
1182
+ def list_devices(self, interface_id: str) -> list[DeviceDescription]:
1183
+ """Return already existing devices to CCU / Homegear."""
1184
+ result = self._device_descriptions.get_raw_device_descriptions(interface_id=interface_id)
1185
+ _LOGGER.debug("LIST_DEVICES: interface_id = %s, channel_count = %i", interface_id, len(result))
1186
+ return result
1187
+
1188
+ def add_event_subscription(self, data_point: BaseParameterDataPoint) -> None:
1189
+ """Add data_point to central event subscription."""
1190
+ if isinstance(data_point, (GenericDataPoint, GenericEvent)) and (
1191
+ data_point.is_readable or data_point.supports_events
1192
+ ):
1193
+ if data_point.dpk not in self._data_point_key_event_subscriptions:
1194
+ self._data_point_key_event_subscriptions[data_point.dpk] = []
1195
+ self._data_point_key_event_subscriptions[data_point.dpk].append(data_point.event)
1196
+ if (
1197
+ not data_point.channel.device.client.supports_xml_rpc
1198
+ and data_point.state_path not in self._data_point_path_event_subscriptions
1199
+ ):
1200
+ self._data_point_path_event_subscriptions[data_point.state_path] = data_point.dpk
1201
+
1202
+ @inspector()
1203
+ async def create_central_links(self) -> None:
1204
+ """Create a central links to support press events on all channels with click events."""
1205
+ for device in self.devices:
1206
+ await device.create_central_links()
1207
+
1208
+ @inspector()
1209
+ async def remove_central_links(self) -> None:
1210
+ """Remove central links."""
1211
+ for device in self.devices:
1212
+ await device.remove_central_links()
1213
+
1214
+ def remove_device(self, device: Device) -> None:
1215
+ """Remove device to central collections."""
1216
+ if device.address not in self._devices:
1217
+ _LOGGER.debug(
1218
+ "REMOVE_DEVICE: device %s not registered in central",
1219
+ device.address,
1220
+ )
1221
+ return
1222
+ device.remove()
1223
+
1224
+ self._device_descriptions.remove_device(device=device)
1225
+ self._paramset_descriptions.remove_device(device=device)
1226
+ self._device_details.remove_device(device=device)
1227
+ del self._devices[device.address]
1228
+
1229
+ def remove_event_subscription(self, data_point: BaseParameterDataPoint) -> None:
1230
+ """Remove event subscription from central collections."""
1231
+ if isinstance(data_point, (GenericDataPoint, GenericEvent)) and data_point.supports_events:
1232
+ if data_point.dpk in self._data_point_key_event_subscriptions:
1233
+ del self._data_point_key_event_subscriptions[data_point.dpk]
1234
+ if data_point.state_path in self._data_point_path_event_subscriptions:
1235
+ del self._data_point_path_event_subscriptions[data_point.state_path]
1236
+
1237
+ def get_last_event_dt(self, interface_id: str) -> datetime | None:
1238
+ """Return the last event dt."""
1239
+ return self._last_events.get(interface_id)
1240
+
1241
+ def set_last_event_dt(self, interface_id: str) -> None:
1242
+ """Set the last event dt."""
1243
+ self._last_events[interface_id] = datetime.now()
1244
+
1245
+ async def execute_program(self, pid: str) -> bool:
1246
+ """Execute a program on CCU / Homegear."""
1247
+ if client := self.primary_client:
1248
+ return await client.execute_program(pid=pid)
1249
+ return False
1250
+
1251
+ async def set_program_state(self, pid: str, state: bool) -> bool:
1252
+ """Execute a program on CCU / Homegear."""
1253
+ if client := self.primary_client:
1254
+ return await client.set_program_state(pid=pid, state=state)
1255
+ return False
1256
+
1257
+ @inspector(re_raise=False)
1258
+ async def fetch_sysvar_data(self, scheduled: bool) -> None:
1259
+ """Fetch sysvar data for the hub."""
1260
+ await self._hub.fetch_sysvar_data(scheduled=scheduled)
1261
+
1262
+ @inspector(re_raise=False)
1263
+ async def fetch_program_data(self, scheduled: bool) -> None:
1264
+ """Fetch program data for the hub."""
1265
+ await self._hub.fetch_program_data(scheduled=scheduled)
1266
+
1267
+ @inspector(measure_performance=True)
1268
+ async def load_and_refresh_data_point_data(
1269
+ self,
1270
+ interface: Interface,
1271
+ paramset_key: ParamsetKey | None = None,
1272
+ direct_call: bool = False,
1273
+ ) -> None:
1274
+ """Refresh data_point data."""
1275
+ if paramset_key != ParamsetKey.MASTER:
1276
+ await self._data_cache.load(interface=interface)
1277
+ await self._data_cache.refresh_data_point_data(
1278
+ paramset_key=paramset_key, interface=interface, direct_call=direct_call
1279
+ )
1280
+
1281
+ async def get_system_variable(self, legacy_name: str) -> Any | None:
1282
+ """Get system variable from CCU / Homegear."""
1283
+ if client := self.primary_client:
1284
+ return await client.get_system_variable(legacy_name)
1285
+ return None
1286
+
1287
+ async def set_system_variable(self, legacy_name: str, value: Any) -> None:
1288
+ """Set variable value on CCU/Homegear."""
1289
+ if dp := self.get_sysvar_data_point(legacy_name=legacy_name):
1290
+ await dp.send_variable(value=value)
1291
+ else:
1292
+ _LOGGER.warning("Variable %s not found on %s", legacy_name, self.name)
1293
+
1294
+ def get_parameters(
1295
+ self,
1296
+ paramset_key: ParamsetKey,
1297
+ operations: tuple[Operations, ...],
1298
+ full_format: bool = False,
1299
+ un_ignore_candidates_only: bool = False,
1300
+ use_channel_wildcard: bool = False,
1301
+ ) -> list[str]:
1302
+ """
1303
+ Return all parameters from VALUES paramset.
1304
+
1305
+ Performance optimized to minimize repeated lookups and computations
1306
+ when iterating over all channels and parameters.
1307
+ """
1308
+ parameters: set[str] = set()
1309
+
1310
+ # Precompute operations mask to avoid repeated checks in the inner loop
1311
+ op_mask: int = 0
1312
+ for op in operations:
1313
+ op_mask |= int(op)
1314
+
1315
+ raw_psd = self._paramset_descriptions.raw_paramset_descriptions
1316
+ ignore_set = IGNORE_FOR_UN_IGNORE_PARAMETERS
1317
+
1318
+ # Prepare optional helpers only if needed
1319
+ get_model = self._device_descriptions.get_model if full_format else None
1320
+ model_cache: dict[str, str | None] = {}
1321
+ channel_no_cache: dict[str, int | None] = {}
1322
+
1323
+ for channels in raw_psd.values():
1324
+ for channel_address, channel_paramsets in channels.items():
1325
+ # Resolve model lazily and cache per device address when full_format is requested
1326
+ model: str | None = None
1327
+ if get_model is not None:
1328
+ dev_addr = get_device_address(address=channel_address)
1329
+ if (model := model_cache.get(dev_addr)) is None:
1330
+ model = get_model(device_address=dev_addr)
1331
+ model_cache[dev_addr] = model
1332
+
1333
+ if (paramset := channel_paramsets.get(paramset_key)) is None:
1334
+ continue
1335
+
1336
+ for parameter, parameter_data in paramset.items():
1337
+ # Fast bitmask check: ensure all requested ops are present
1338
+ if (int(parameter_data["OPERATIONS"]) & op_mask) != op_mask:
1339
+ continue
1340
+
1341
+ if un_ignore_candidates_only:
1342
+ # Cheap check first to avoid expensive dp lookup when possible
1343
+ if parameter in ignore_set:
1344
+ continue
1345
+ dp = self.get_generic_data_point(
1346
+ channel_address=channel_address,
1347
+ parameter=parameter,
1348
+ paramset_key=paramset_key,
1349
+ )
1350
+ if dp and dp.enabled_default and not dp.is_un_ignored:
1351
+ continue
1352
+
1353
+ if not full_format:
1354
+ parameters.add(parameter)
1355
+ continue
1356
+
1357
+ if use_channel_wildcard:
1358
+ channel_repr: int | str | None = UN_IGNORE_WILDCARD
1359
+ elif channel_address in channel_no_cache:
1360
+ channel_repr = channel_no_cache[channel_address]
1361
+ else:
1362
+ channel_repr = get_channel_no(address=channel_address)
1363
+ channel_no_cache[channel_address] = channel_repr
1364
+
1365
+ # Build the full parameter string
1366
+ if channel_repr is None:
1367
+ parameters.add(f"{parameter}:{paramset_key}@{model}:")
1368
+ else:
1369
+ parameters.add(f"{parameter}:{paramset_key}@{model}:{channel_repr}")
1370
+
1371
+ return list(parameters)
1372
+
1373
+ def _get_virtual_remote(self, device_address: str) -> Device | None:
1374
+ """Get the virtual remote for the Client."""
1375
+ for client in self._clients.values():
1376
+ virtual_remote = client.get_virtual_remote()
1377
+ if virtual_remote and virtual_remote.address == device_address:
1378
+ return virtual_remote
1379
+ return None
1380
+
1381
+ def get_generic_data_point(
1382
+ self, channel_address: str, parameter: str, paramset_key: ParamsetKey | None = None
1383
+ ) -> GenericDataPoint | None:
1384
+ """Get data_point by channel_address and parameter."""
1385
+ if device := self.get_device(address=channel_address):
1386
+ return device.get_generic_data_point(
1387
+ channel_address=channel_address, parameter=parameter, paramset_key=paramset_key
1388
+ )
1389
+ return None
1390
+
1391
+ def get_event(self, channel_address: str, parameter: str) -> GenericEvent | None:
1392
+ """Return the hm event."""
1393
+ if device := self.get_device(address=channel_address):
1394
+ return device.get_generic_event(channel_address=channel_address, parameter=parameter)
1395
+ return None
1396
+
1397
+ def get_custom_data_point(self, address: str, channel_no: int) -> CustomDataPoint | None:
1398
+ """Return the hm custom_data_point."""
1399
+ if device := self.get_device(address=address):
1400
+ return device.get_custom_data_point(channel_no=channel_no)
1401
+ return None
1402
+
1403
+ def get_sysvar_data_point(
1404
+ self, vid: str | None = None, legacy_name: str | None = None
1405
+ ) -> GenericSysvarDataPoint | None:
1406
+ """Return the sysvar data_point."""
1407
+ if vid and (sysvar := self._sysvar_data_points.get(vid)):
1408
+ return sysvar
1409
+ if legacy_name:
1410
+ for sysvar in self._sysvar_data_points.values():
1411
+ if sysvar.legacy_name == legacy_name:
1412
+ return sysvar
1413
+ return None
1414
+
1415
+ def get_program_data_point(self, pid: str | None = None, legacy_name: str | None = None) -> ProgramDpType | None:
1416
+ """Return the program data points."""
1417
+ if pid and (program := self._program_data_points.get(pid)):
1418
+ return program
1419
+ if legacy_name:
1420
+ for program in self._program_data_points.values():
1421
+ if legacy_name in (program.button.legacy_name, program.switch.legacy_name):
1422
+ return program
1423
+ return None
1424
+
1425
+ def get_data_point_path(self) -> tuple[str, ...]:
1426
+ """Return the registered state path."""
1427
+ return tuple(self._data_point_path_event_subscriptions)
1428
+
1429
+ def get_sysvar_data_point_path(self) -> tuple[str, ...]:
1430
+ """Return the registered sysvar state path."""
1431
+ return tuple(self._sysvar_data_point_event_subscriptions)
1432
+
1433
+ def get_un_ignore_candidates(self, include_master: bool = False) -> list[str]:
1434
+ """Return the candidates for un_ignore."""
1435
+ candidates = sorted(
1436
+ # 1. request simple parameter list for values parameters
1437
+ self.get_parameters(
1438
+ paramset_key=ParamsetKey.VALUES,
1439
+ operations=(Operations.READ, Operations.EVENT),
1440
+ un_ignore_candidates_only=True,
1441
+ )
1442
+ # 2. request full_format parameter list with channel wildcard for values parameters
1443
+ + self.get_parameters(
1444
+ paramset_key=ParamsetKey.VALUES,
1445
+ operations=(Operations.READ, Operations.EVENT),
1446
+ full_format=True,
1447
+ un_ignore_candidates_only=True,
1448
+ use_channel_wildcard=True,
1449
+ )
1450
+ # 3. request full_format parameter list for values parameters
1451
+ + self.get_parameters(
1452
+ paramset_key=ParamsetKey.VALUES,
1453
+ operations=(Operations.READ, Operations.EVENT),
1454
+ full_format=True,
1455
+ un_ignore_candidates_only=True,
1456
+ )
1457
+ )
1458
+ if include_master:
1459
+ # 4. request full_format parameter list for master parameters
1460
+ candidates += sorted(
1461
+ self.get_parameters(
1462
+ paramset_key=ParamsetKey.MASTER,
1463
+ operations=(Operations.READ,),
1464
+ full_format=True,
1465
+ un_ignore_candidates_only=True,
1466
+ )
1467
+ )
1468
+ return candidates
1469
+
1470
+ async def clear_caches(self) -> None:
1471
+ """Clear all stored data."""
1472
+ await self._device_descriptions.clear()
1473
+ await self._paramset_descriptions.clear()
1474
+ self._device_details.clear()
1475
+ self._data_cache.clear()
1476
+
1477
+ def register_homematic_callback(self, cb: Callable) -> CALLBACK_TYPE:
1478
+ """Register ha_event callback in central."""
1479
+ if callable(cb) and cb not in self._homematic_callbacks:
1480
+ self._homematic_callbacks.add(cb)
1481
+ return partial(self._unregister_homematic_callback, cb=cb)
1482
+ return None
1483
+
1484
+ def _unregister_homematic_callback(self, cb: Callable) -> None:
1485
+ """RUn register ha_event callback in central."""
1486
+ if cb in self._homematic_callbacks:
1487
+ self._homematic_callbacks.remove(cb)
1488
+
1489
+ @loop_check
1490
+ def fire_homematic_callback(self, event_type: EventType, event_data: dict[EventKey, str]) -> None:
1491
+ """
1492
+ Fire homematic_callback in central.
1493
+
1494
+ # Events like INTERFACE, KEYPRESS, ...
1495
+ """
1496
+ for callback_handler in self._homematic_callbacks:
1497
+ try:
1498
+ callback_handler(event_type, event_data)
1499
+ except Exception as exc:
1500
+ _LOGGER.error(
1501
+ "FIRE_HOMEMATIC_CALLBACK: Unable to call handler: %s",
1502
+ extract_exc_args(exc=exc),
1503
+ )
1504
+
1505
+ def register_backend_parameter_callback(self, cb: Callable) -> CALLBACK_TYPE:
1506
+ """Register backend_parameter callback in central."""
1507
+ if callable(cb) and cb not in self._backend_parameter_callbacks:
1508
+ self._backend_parameter_callbacks.add(cb)
1509
+ return partial(self._unregister_backend_parameter_callback, cb=cb)
1510
+ return None
1511
+
1512
+ def _unregister_backend_parameter_callback(self, cb: Callable) -> None:
1513
+ """Un register backend_parameter callback in central."""
1514
+ if cb in self._backend_parameter_callbacks:
1515
+ self._backend_parameter_callbacks.remove(cb)
1516
+
1517
+ @loop_check
1518
+ def fire_backend_parameter_callback(
1519
+ self, interface_id: str, channel_address: str, parameter: str, value: Any
1520
+ ) -> None:
1521
+ """
1522
+ Fire backend_parameter callback in central.
1523
+
1524
+ Re-Fired events from CCU for parameter updates.
1525
+ """
1526
+ for callback_handler in self._backend_parameter_callbacks:
1527
+ try:
1528
+ callback_handler(interface_id, channel_address, parameter, value)
1529
+ except Exception as exc:
1530
+ _LOGGER.error(
1531
+ "FIRE_BACKEND_PARAMETER_CALLBACK: Unable to call handler: %s",
1532
+ extract_exc_args(exc=exc),
1533
+ )
1534
+
1535
+ def register_backend_system_callback(self, cb: Callable) -> CALLBACK_TYPE:
1536
+ """Register system_event callback in central."""
1537
+ if callable(cb) and cb not in self._backend_parameter_callbacks:
1538
+ self._backend_system_callbacks.add(cb)
1539
+ return partial(self._unregister_backend_system_callback, cb=cb)
1540
+ return None
1541
+
1542
+ def _unregister_backend_system_callback(self, cb: Callable) -> None:
1543
+ """Un register system_event callback in central."""
1544
+ if cb in self._backend_system_callbacks:
1545
+ self._backend_system_callbacks.remove(cb)
1546
+
1547
+ @loop_check
1548
+ def fire_backend_system_callback(self, system_event: BackendSystemEvent, **kwargs: Any) -> None:
1549
+ """
1550
+ Fire system_event callback in central.
1551
+
1552
+ e.g. DEVICES_CREATED, HUB_REFRESHED
1553
+ """
1554
+ for callback_handler in self._backend_system_callbacks:
1555
+ try:
1556
+ callback_handler(system_event, **kwargs)
1557
+ except Exception as exc:
1558
+ _LOGGER.error(
1559
+ "FIRE_BACKEND_SYSTEM_CALLBACK: Unable to call handler: %s",
1560
+ extract_exc_args(exc=exc),
1561
+ )
1562
+
1563
+ def __str__(self) -> str:
1564
+ """Provide some useful information."""
1565
+ return f"central: {self.name}"
1566
+
1567
+
1568
+ class _Scheduler(threading.Thread):
1569
+ """Periodically check connection to CCU / Homegear, and load data when required."""
1570
+
1571
+ def __init__(self, central: CentralUnit) -> None:
1572
+ """Init the connection checker."""
1573
+ threading.Thread.__init__(self, name=f"ConnectionChecker for {central.name}")
1574
+ self._central: Final = central
1575
+ self._unregister_callback = self._central.register_backend_system_callback(cb=self._backend_system_callback)
1576
+ self._active = True
1577
+ self._devices_created = False
1578
+ self._scheduler_jobs = [
1579
+ _SchedulerJob(task=self._check_connection, run_interval=CONNECTION_CHECKER_INTERVAL),
1580
+ _SchedulerJob(
1581
+ task=self._refresh_client_data,
1582
+ run_interval=self._central.config.periodic_refresh_interval,
1583
+ ),
1584
+ _SchedulerJob(
1585
+ task=self._refresh_program_data,
1586
+ run_interval=self._central.config.sys_scan_interval,
1587
+ ),
1588
+ _SchedulerJob(task=self._refresh_sysvar_data, run_interval=self._central.config.sys_scan_interval),
1589
+ _SchedulerJob(
1590
+ task=self._fetch_device_firmware_update_data,
1591
+ run_interval=DEVICE_FIRMWARE_CHECK_INTERVAL,
1592
+ ),
1593
+ _SchedulerJob(
1594
+ task=self._fetch_device_firmware_update_data_in_delivery,
1595
+ run_interval=DEVICE_FIRMWARE_DELIVERING_CHECK_INTERVAL,
1596
+ ),
1597
+ _SchedulerJob(
1598
+ task=self._fetch_device_firmware_update_data_in_update,
1599
+ run_interval=DEVICE_FIRMWARE_UPDATING_CHECK_INTERVAL,
1600
+ ),
1601
+ ]
1602
+
1603
+ def _backend_system_callback(self, system_event: BackendSystemEvent, **kwargs: Any) -> None:
1604
+ """Handle event of new device creation, to delay the start of the sysvar scan."""
1605
+ if system_event == BackendSystemEvent.DEVICES_CREATED:
1606
+ self._devices_created = True
1607
+
1608
+ def run(self) -> None:
1609
+ """Run the scheduler thread."""
1610
+ _LOGGER.debug(
1611
+ "run: scheduler for %s",
1612
+ self._central.name,
1613
+ )
1614
+
1615
+ self._central.looper.create_task(
1616
+ self._run_scheduler_tasks(),
1617
+ name="run_scheduler_tasks",
1618
+ )
1619
+
1620
+ def stop(self) -> None:
1621
+ """To stop the ConnectionChecker."""
1622
+ if self._unregister_callback is not None:
1623
+ self._unregister_callback()
1624
+ self._active = False
1625
+
1626
+ async def _run_scheduler_tasks(self) -> None:
1627
+ """Run all tasks."""
1628
+ while self._active:
1629
+ if not self._central.started:
1630
+ _LOGGER.debug("SCHEDULER: Waiting till central %s is started", self._central.name)
1631
+ await asyncio.sleep(SCHEDULER_NOT_STARTED_SLEEP)
1632
+ continue
1633
+ for job in self._scheduler_jobs:
1634
+ if not self._active or not job.ready:
1635
+ continue
1636
+ await job.run()
1637
+ job.schedule_next_execution()
1638
+ if self._active:
1639
+ await asyncio.sleep(SCHEDULER_LOOP_SLEEP)
1640
+
1641
+ async def _check_connection(self) -> None:
1642
+ """Check connection to backend."""
1643
+ _LOGGER.debug("CHECK_CONNECTION: Checking connection to server %s", self._central.name)
1644
+ try:
1645
+ if not self._central.all_clients_active:
1646
+ _LOGGER.warning(
1647
+ "CHECK_CONNECTION failed: No clients exist. Trying to create clients for server %s",
1648
+ self._central.name,
1649
+ )
1650
+ await self._central.restart_clients()
1651
+ else:
1652
+ reconnects: list[Any] = []
1653
+ reloads: list[Any] = []
1654
+ for interface_id in self._central.interface_ids:
1655
+ # check:
1656
+ # - client is available
1657
+ # - client is connected
1658
+ # - interface callback is alive
1659
+ client = self._central.get_client(interface_id=interface_id)
1660
+ if client.available is False or not await client.is_connected() or not client.is_callback_alive():
1661
+ reconnects.append(client.reconnect())
1662
+ reloads.append(self._central.load_and_refresh_data_point_data(interface=client.interface))
1663
+ if reconnects:
1664
+ await asyncio.gather(*reconnects)
1665
+ if self._central.available:
1666
+ await asyncio.gather(*reloads)
1667
+ except NoConnectionException as nex:
1668
+ _LOGGER.error("CHECK_CONNECTION failed: no connection: %s", extract_exc_args(exc=nex))
1669
+ except Exception as exc:
1670
+ _LOGGER.error(
1671
+ "CHECK_CONNECTION failed: %s [%s]",
1672
+ type(exc).__name__,
1673
+ extract_exc_args(exc=exc),
1674
+ )
1675
+
1676
+ @inspector(re_raise=False)
1677
+ async def _refresh_client_data(self) -> None:
1678
+ """Refresh client data."""
1679
+ if not self._central.available:
1680
+ return
1681
+
1682
+ if (poll_clients := self._central.poll_clients) is not None and len(poll_clients) > 0:
1683
+ _LOGGER.debug("REFRESH_CLIENT_DATA: Loading data for %s", self._central.name)
1684
+ for client in poll_clients:
1685
+ await self._central.load_and_refresh_data_point_data(interface=client.interface)
1686
+ self._central.set_last_event_dt(interface_id=client.interface_id)
1687
+
1688
+ @inspector(re_raise=False)
1689
+ async def _refresh_sysvar_data(self) -> None:
1690
+ """Refresh system variables."""
1691
+ if not self._central.config.enable_sysvar_scan or not self._central.available or not self._devices_created:
1692
+ return
1693
+
1694
+ _LOGGER.debug("REFRESH_SYSVAR_DATA: For %s", self._central.name)
1695
+ await self._central.fetch_sysvar_data(scheduled=True)
1696
+
1697
+ @inspector(re_raise=False)
1698
+ async def _refresh_program_data(self) -> None:
1699
+ """Refresh system program_data."""
1700
+ if not self._central.config.enable_program_scan or not self._central.available or not self._devices_created:
1701
+ return
1702
+
1703
+ _LOGGER.debug("REFRESH_PROGRAM_DATA: For %s", self._central.name)
1704
+ await self._central.fetch_program_data(scheduled=True)
1705
+
1706
+ @inspector(re_raise=False)
1707
+ async def _fetch_device_firmware_update_data(self) -> None:
1708
+ """Periodically fetch device firmware update data from backend."""
1709
+ if (
1710
+ not self._central.config.enable_device_firmware_check
1711
+ or not self._central.available
1712
+ or not self._devices_created
1713
+ ):
1714
+ return
1715
+
1716
+ _LOGGER.debug(
1717
+ "FETCH_DEVICE_FIRMWARE_UPDATE_DATA: Scheduled fetching of device firmware update data for %s",
1718
+ self._central.name,
1719
+ )
1720
+ await self._central.refresh_firmware_data()
1721
+
1722
+ @inspector(re_raise=False)
1723
+ async def _fetch_device_firmware_update_data_in_delivery(self) -> None:
1724
+ """Periodically fetch device firmware update data from backend."""
1725
+ if (
1726
+ not self._central.config.enable_device_firmware_check
1727
+ or not self._central.available
1728
+ or not self._devices_created
1729
+ ):
1730
+ return
1731
+
1732
+ _LOGGER.debug(
1733
+ "FETCH_DEVICE_FIRMWARE_UPDATE_DATA_IN_DELIVERY: Scheduled fetching of device firmware update data for delivering devices for %s",
1734
+ self._central.name,
1735
+ )
1736
+ await self._central.refresh_firmware_data_by_state(
1737
+ device_firmware_states=(
1738
+ DeviceFirmwareState.DELIVER_FIRMWARE_IMAGE,
1739
+ DeviceFirmwareState.LIVE_DELIVER_FIRMWARE_IMAGE,
1740
+ )
1741
+ )
1742
+
1743
+ @inspector(re_raise=False)
1744
+ async def _fetch_device_firmware_update_data_in_update(self) -> None:
1745
+ """Periodically fetch device firmware update data from backend."""
1746
+ if (
1747
+ not self._central.config.enable_device_firmware_check
1748
+ or not self._central.available
1749
+ or not self._devices_created
1750
+ ):
1751
+ return
1752
+
1753
+ _LOGGER.debug(
1754
+ "FETCH_DEVICE_FIRMWARE_UPDATE_DATA_IN_UPDATE: Scheduled fetching of device firmware update data for updating devices for %s",
1755
+ self._central.name,
1756
+ )
1757
+ await self._central.refresh_firmware_data_by_state(
1758
+ device_firmware_states=(
1759
+ DeviceFirmwareState.READY_FOR_UPDATE,
1760
+ DeviceFirmwareState.DO_UPDATE_PENDING,
1761
+ DeviceFirmwareState.PERFORMING_UPDATE,
1762
+ )
1763
+ )
1764
+
1765
+
1766
+ class _SchedulerJob:
1767
+ """Job to run in the scheduler."""
1768
+
1769
+ def __init__(
1770
+ self,
1771
+ task: Callable,
1772
+ run_interval: int,
1773
+ next_run: datetime | None = None,
1774
+ ):
1775
+ """Init the job."""
1776
+ self._task: Final = task
1777
+ self._next_run = next_run or datetime.now()
1778
+ self._run_interval: Final = run_interval
1779
+
1780
+ @property
1781
+ def ready(self) -> bool:
1782
+ """Return if the job can be executed."""
1783
+ return self._next_run < datetime.now()
1784
+
1785
+ async def run(self) -> None:
1786
+ """Run the task."""
1787
+ await self._task()
1788
+
1789
+ def schedule_next_execution(self) -> None:
1790
+ """Schedule the next execution of the job."""
1791
+ self._next_run += timedelta(seconds=self._run_interval)
1792
+
1793
+
1794
+ class CentralConfig:
1795
+ """Config for a Client."""
1796
+
1797
+ def __init__(
1798
+ self,
1799
+ central_id: str,
1800
+ default_callback_port: int,
1801
+ host: str,
1802
+ interface_configs: AbstractSet[hmcl.InterfaceConfig],
1803
+ name: str,
1804
+ password: str,
1805
+ storage_folder: str,
1806
+ username: str,
1807
+ client_session: ClientSession | None = None,
1808
+ callback_host: str | None = None,
1809
+ callback_port: int | None = None,
1810
+ enable_device_firmware_check: bool = DEFAULT_ENABLE_DEVICE_FIRMWARE_CHECK,
1811
+ enable_program_scan: bool = DEFAULT_ENABLE_PROGRAM_SCAN,
1812
+ enable_sysvar_scan: bool = DEFAULT_ENABLE_SYSVAR_SCAN,
1813
+ hm_master_poll_after_send_intervals: tuple[int, ...] = DEFAULT_HM_MASTER_POLL_AFTER_SEND_INTERVALS,
1814
+ ignore_custom_device_definition_models: tuple[str, ...] = DEFAULT_IGNORE_CUSTOM_DEVICE_DEFINITION_MODELS,
1815
+ interfaces_requiring_periodic_refresh: tuple[Interface, ...] = INTERFACES_REQUIRING_PERIODIC_REFRESH,
1816
+ json_port: int | None = None,
1817
+ listen_ip_addr: str | None = None,
1818
+ listen_port: int | None = None,
1819
+ max_read_workers: int = DEFAULT_MAX_READ_WORKERS,
1820
+ periodic_refresh_interval: int = DEFAULT_PERIODIC_REFRESH_INTERVAL,
1821
+ program_markers: tuple[DescriptionMarker | str, ...] = DEFAULT_PROGRAM_MARKERS,
1822
+ start_direct: bool = False,
1823
+ sys_scan_interval: int = DEFAULT_SYS_SCAN_INTERVAL,
1824
+ sysvar_markers: tuple[DescriptionMarker | str, ...] = DEFAULT_SYSVAR_MARKERS,
1825
+ tls: bool = DEFAULT_TLS,
1826
+ un_ignore_list: tuple[str, ...] = DEFAULT_UN_IGNORES,
1827
+ verify_tls: bool = DEFAULT_VERIFY_TLS,
1828
+ ) -> None:
1829
+ """Init the client config."""
1830
+ self._interface_configs: Final = interface_configs
1831
+ self.callback_host: Final = callback_host
1832
+ self.callback_port: Final = callback_port
1833
+ self.central_id: Final = central_id
1834
+ self.client_session: Final = client_session
1835
+ self.default_callback_port: Final = default_callback_port
1836
+ self.enable_device_firmware_check: Final = enable_device_firmware_check
1837
+ self.enable_program_scan: Final = enable_program_scan
1838
+ self.enable_sysvar_scan: Final = enable_sysvar_scan
1839
+ self.hm_master_poll_after_send_intervals: Final = hm_master_poll_after_send_intervals
1840
+ self.host: Final = host
1841
+ self.ignore_custom_device_definition_models: Final = ignore_custom_device_definition_models
1842
+ self.interfaces_requiring_periodic_refresh: Final = interfaces_requiring_periodic_refresh
1843
+ self.json_port: Final = json_port
1844
+ self.listen_ip_addr: Final = listen_ip_addr
1845
+ self.listen_port: Final = listen_port
1846
+ self.max_read_workers = max_read_workers
1847
+ self.name: Final = name
1848
+ self.password: Final = password
1849
+ self.periodic_refresh_interval = periodic_refresh_interval
1850
+ self.program_markers: Final = program_markers
1851
+ self.start_direct: Final = start_direct
1852
+ self.storage_folder: Final = storage_folder
1853
+ self.sys_scan_interval: Final = sys_scan_interval
1854
+ self.sysvar_markers: Final = sysvar_markers
1855
+ self.tls: Final = tls
1856
+ self.un_ignore_list: Final = un_ignore_list
1857
+ self.username: Final = username
1858
+ self.verify_tls: Final = verify_tls
1859
+
1860
+ @property
1861
+ def enable_server(self) -> bool:
1862
+ """Return if server and connection checker should be started."""
1863
+ return self.start_direct is False
1864
+
1865
+ @property
1866
+ def load_un_ignore(self) -> bool:
1867
+ """Return if un_ignore should be loaded."""
1868
+ return self.start_direct is False
1869
+
1870
+ @property
1871
+ def connection_check_port(self) -> int:
1872
+ """Return the connection check port."""
1873
+ if used_ports := tuple(ic.port for ic in self._interface_configs if ic.port is not None):
1874
+ return used_ports[0]
1875
+ if self.json_port:
1876
+ return self.json_port
1877
+ return 443 if self.tls else 80
1878
+
1879
+ @property
1880
+ def enabled_interface_configs(self) -> tuple[hmcl.InterfaceConfig, ...]:
1881
+ """Return the interface configs."""
1882
+ return tuple(ic for ic in self._interface_configs if ic.enabled is True)
1883
+
1884
+ @property
1885
+ def use_caches(self) -> bool:
1886
+ """Return if caches should be used."""
1887
+ return self.start_direct is False
1888
+
1889
+ def check_config(self) -> None:
1890
+ """Check config. Throws BaseHomematicException on failure."""
1891
+ if config_failures := check_config(
1892
+ central_name=self.name,
1893
+ host=self.host,
1894
+ username=self.username,
1895
+ password=self.password,
1896
+ storage_folder=self.storage_folder,
1897
+ callback_host=self.callback_host,
1898
+ callback_port=self.callback_port,
1899
+ json_port=self.json_port,
1900
+ interface_configs=self._interface_configs,
1901
+ ):
1902
+ failures = ", ".join(config_failures)
1903
+ raise AioHomematicConfigException(failures)
1904
+
1905
+ def create_central(self) -> CentralUnit:
1906
+ """Create the central. Throws BaseHomematicException on validation failure."""
1907
+ try:
1908
+ self.check_config()
1909
+ return CentralUnit(self)
1910
+ except BaseHomematicException as bhexc:
1911
+ raise AioHomematicException(
1912
+ f"CREATE_CENTRAL: Not able to create a central: : {extract_exc_args(exc=bhexc)}"
1913
+ ) from bhexc
1914
+
1915
+ def create_central_url(self) -> str:
1916
+ """Return the required url."""
1917
+ url = "https://" if self.tls else "http://"
1918
+ url = f"{url}{self.host}"
1919
+ if self.json_port:
1920
+ url = f"{url}:{self.json_port}"
1921
+ return f"{url}"
1922
+
1923
+ def create_json_rpc_client(self, central: CentralUnit) -> JsonRpcAioHttpClient:
1924
+ """Create a json rpc client."""
1925
+ return JsonRpcAioHttpClient(
1926
+ username=self.username,
1927
+ password=self.password,
1928
+ device_url=central.url,
1929
+ connection_state=central.connection_state,
1930
+ client_session=self.client_session,
1931
+ tls=self.tls,
1932
+ verify_tls=self.verify_tls,
1933
+ )
1934
+
1935
+
1936
+ class CentralConnectionState:
1937
+ """The central connection status."""
1938
+
1939
+ def __init__(self) -> None:
1940
+ """Init the CentralConnectionStatus."""
1941
+ self._json_issues: Final[list[str]] = []
1942
+ self._xml_proxy_issues: Final[list[str]] = []
1943
+
1944
+ def add_issue(self, issuer: ConnectionProblemIssuer, iid: str) -> bool:
1945
+ """Add issue to collection."""
1946
+ if isinstance(issuer, JsonRpcAioHttpClient) and iid not in self._json_issues:
1947
+ self._json_issues.append(iid)
1948
+ _LOGGER.debug("add_issue: add issue [%s] for JsonRpcAioHttpClient", iid)
1949
+ return True
1950
+ if isinstance(issuer, XmlRpcProxy) and iid not in self._xml_proxy_issues:
1951
+ self._xml_proxy_issues.append(iid)
1952
+ _LOGGER.debug("add_issue: add issue [%s] for %s", iid, issuer.interface_id)
1953
+ return True
1954
+ return False
1955
+
1956
+ def remove_issue(self, issuer: ConnectionProblemIssuer, iid: str) -> bool:
1957
+ """Add issue to collection."""
1958
+ if isinstance(issuer, JsonRpcAioHttpClient) and iid in self._json_issues:
1959
+ self._json_issues.remove(iid)
1960
+ _LOGGER.debug("remove_issue: removing issue [%s] for JsonRpcAioHttpClient", iid)
1961
+ return True
1962
+ if isinstance(issuer, XmlRpcProxy) and issuer.interface_id in self._xml_proxy_issues:
1963
+ self._xml_proxy_issues.remove(iid)
1964
+ _LOGGER.debug("remove_issue: removing issue [%s] for %s", iid, issuer.interface_id)
1965
+ return True
1966
+ return False
1967
+
1968
+ def has_issue(self, issuer: ConnectionProblemIssuer, iid: str) -> bool:
1969
+ """Add issue to collection."""
1970
+ if isinstance(issuer, JsonRpcAioHttpClient):
1971
+ return iid in self._json_issues
1972
+ if isinstance(issuer, XmlRpcProxy):
1973
+ return iid in self._xml_proxy_issues
1974
+
1975
+ def handle_exception_log(
1976
+ self,
1977
+ issuer: ConnectionProblemIssuer,
1978
+ iid: str,
1979
+ exception: Exception,
1980
+ logger: logging.Logger = _LOGGER,
1981
+ level: int = logging.ERROR,
1982
+ extra_msg: str = "",
1983
+ multiple_logs: bool = True,
1984
+ ) -> None:
1985
+ """Handle Exception and derivates logging."""
1986
+ exception_name = exception.name if hasattr(exception, "name") else exception.__class__.__name__
1987
+ if self.has_issue(issuer=issuer, iid=iid) and multiple_logs is False:
1988
+ logger.debug(
1989
+ "%s failed: %s [%s] %s",
1990
+ iid,
1991
+ exception_name,
1992
+ extract_exc_args(exc=exception),
1993
+ extra_msg,
1994
+ )
1995
+ else:
1996
+ self.add_issue(issuer=issuer, iid=iid)
1997
+ logger.log(
1998
+ level,
1999
+ "%s failed: %s [%s] %s",
2000
+ iid,
2001
+ exception_name,
2002
+ extract_exc_args(exc=exception),
2003
+ extra_msg,
2004
+ )
2005
+
2006
+
2007
+ def _get_new_data_points(
2008
+ new_devices: set[Device],
2009
+ ) -> Mapping[DataPointCategory, AbstractSet[CallbackDataPoint]]:
2010
+ """Return new data points by category."""
2011
+
2012
+ data_points_by_category: dict[DataPointCategory, set[CallbackDataPoint]] = {
2013
+ category: set() for category in CATEGORIES if category != DataPointCategory.EVENT
2014
+ }
2015
+
2016
+ for device in new_devices:
2017
+ for category, data_points in data_points_by_category.items():
2018
+ data_points.update(device.get_data_points(category=category, exclude_no_create=True, registered=False))
2019
+
2020
+ return data_points_by_category
2021
+
2022
+
2023
+ def _get_new_channel_events(new_devices: set[Device]) -> tuple[tuple[GenericEvent, ...], ...]:
2024
+ """Return new channel events by category."""
2025
+ channel_events: list[tuple[GenericEvent, ...]] = []
2026
+
2027
+ for device in new_devices:
2028
+ for event_type in DATA_POINT_EVENTS:
2029
+ if (hm_channel_events := list(device.get_events(event_type=event_type, registered=False).values())) and len(
2030
+ hm_channel_events
2031
+ ) > 0:
2032
+ channel_events.append(hm_channel_events) # type: ignore[arg-type] # noqa:PERF401
2033
+
2034
+ return tuple(channel_events)