aiohomematic 2025.10.20b0__tar.gz → 2025.10.22__tar.gz

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

Potentially problematic release.


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

Files changed (83) hide show
  1. {aiohomematic-2025.10.20b0/aiohomematic.egg-info → aiohomematic-2025.10.22}/PKG-INFO +1 -1
  2. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/central/__init__.py +55 -1
  3. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/client/__init__.py +17 -15
  4. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/const.py +3 -3
  5. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/custom/data_point.py +5 -5
  6. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/support.py +2 -2
  7. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/store/dynamic.py +74 -95
  8. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/store/visibility.py +1 -1
  9. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/support.py +1 -53
  10. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22/aiohomematic.egg-info}/PKG-INFO +1 -1
  11. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/pyproject.toml +1 -0
  12. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/LICENSE +0 -0
  13. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/MANIFEST.in +0 -0
  14. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/README.md +0 -0
  15. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/__init__.py +0 -0
  16. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/async_support.py +0 -0
  17. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/central/decorators.py +0 -0
  18. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/central/rpc_server.py +0 -0
  19. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/client/_rpc_errors.py +0 -0
  20. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/client/json_rpc.py +0 -0
  21. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/client/rpc_proxy.py +0 -0
  22. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/context.py +0 -0
  23. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/converter.py +0 -0
  24. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/decorators.py +0 -0
  25. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/exceptions.py +0 -0
  26. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/hmcli.py +0 -0
  27. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/__init__.py +0 -0
  28. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/calculated/__init__.py +0 -0
  29. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/calculated/climate.py +0 -0
  30. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/calculated/data_point.py +0 -0
  31. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/calculated/operating_voltage_level.py +0 -0
  32. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/calculated/support.py +0 -0
  33. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/custom/__init__.py +0 -0
  34. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/custom/climate.py +0 -0
  35. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/custom/const.py +0 -0
  36. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/custom/cover.py +0 -0
  37. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/custom/definition.py +0 -0
  38. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/custom/light.py +0 -0
  39. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/custom/lock.py +0 -0
  40. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/custom/siren.py +0 -0
  41. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/custom/support.py +0 -0
  42. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/custom/switch.py +0 -0
  43. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/custom/valve.py +0 -0
  44. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/data_point.py +0 -0
  45. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/device.py +0 -0
  46. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/event.py +0 -0
  47. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/generic/__init__.py +0 -0
  48. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/generic/action.py +0 -0
  49. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/generic/binary_sensor.py +0 -0
  50. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/generic/button.py +0 -0
  51. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/generic/data_point.py +0 -0
  52. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/generic/number.py +0 -0
  53. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/generic/select.py +0 -0
  54. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/generic/sensor.py +0 -0
  55. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/generic/switch.py +0 -0
  56. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/generic/text.py +0 -0
  57. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/hub/__init__.py +0 -0
  58. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/hub/binary_sensor.py +0 -0
  59. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/hub/button.py +0 -0
  60. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/hub/data_point.py +0 -0
  61. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/hub/number.py +0 -0
  62. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/hub/select.py +0 -0
  63. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/hub/sensor.py +0 -0
  64. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/hub/switch.py +0 -0
  65. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/hub/text.py +0 -0
  66. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/model/update.py +0 -0
  67. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/property_decorators.py +0 -0
  68. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/py.typed +0 -0
  69. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/rega_scripts/fetch_all_device_data.fn +0 -0
  70. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/rega_scripts/get_program_descriptions.fn +0 -0
  71. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/rega_scripts/get_serial.fn +0 -0
  72. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/rega_scripts/get_system_variable_descriptions.fn +0 -0
  73. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/rega_scripts/set_program_state.fn +0 -0
  74. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/rega_scripts/set_system_variable.fn +0 -0
  75. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/store/__init__.py +0 -0
  76. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/store/persistent.py +0 -0
  77. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic/validator.py +0 -0
  78. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic.egg-info/SOURCES.txt +0 -0
  79. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic.egg-info/dependency_links.txt +0 -0
  80. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic.egg-info/requires.txt +0 -0
  81. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/aiohomematic.egg-info/top_level.txt +0 -0
  82. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/requirements.txt +0 -0
  83. {aiohomematic-2025.10.20b0 → aiohomematic-2025.10.22}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiohomematic
3
- Version: 2025.10.20b0
3
+ Version: 2025.10.22
4
4
  Summary: Homematic interface for Home Assistant running on Python 3.
5
5
  Home-page: https://github.com/sukramj/aiohomematic
6
6
  Author-email: SukramJ <sukramj@icloud.com>, Daniel Perna <danielperna84@gmail.com>
@@ -110,6 +110,7 @@ from aiohomematic.const import (
110
110
  DEVICE_FIRMWARE_CHECK_INTERVAL,
111
111
  DEVICE_FIRMWARE_DELIVERING_CHECK_INTERVAL,
112
112
  DEVICE_FIRMWARE_UPDATING_CHECK_INTERVAL,
113
+ IDENTIFIER_SEPARATOR,
113
114
  IGNORE_FOR_UN_IGNORE_PARAMETERS,
114
115
  IP_ANY_V4,
115
116
  LOCAL_HOST,
@@ -173,12 +174,16 @@ from aiohomematic.store import (
173
174
  from aiohomematic.support import (
174
175
  LogContextMixin,
175
176
  PayloadMixin,
176
- check_config,
177
+ check_or_create_directory,
178
+ check_password,
177
179
  extract_device_addresses_from_device_descriptions,
178
180
  extract_exc_args,
179
181
  get_channel_no,
180
182
  get_device_address,
181
183
  get_ip_addr,
184
+ is_hostname,
185
+ is_ipv4_address,
186
+ is_port,
182
187
  )
183
188
 
184
189
  __all__ = ["CentralConfig", "CentralUnit", "INTERFACE_EVENT_SCHEMA"]
@@ -2219,6 +2224,55 @@ class CentralConnectionState:
2219
2224
  )
2220
2225
 
2221
2226
 
2227
+ def check_config(
2228
+ *,
2229
+ central_name: str,
2230
+ host: str,
2231
+ username: str,
2232
+ password: str,
2233
+ storage_directory: str,
2234
+ callback_host: str | None,
2235
+ callback_port_xml_rpc: int | None,
2236
+ json_port: int | None,
2237
+ interface_configs: AbstractSet[hmcl.InterfaceConfig] | None = None,
2238
+ ) -> list[str]:
2239
+ """Check config. Throws BaseHomematicException on failure."""
2240
+ config_failures: list[str] = []
2241
+ if central_name and IDENTIFIER_SEPARATOR in central_name:
2242
+ config_failures.append(f"Instance name must not contain {IDENTIFIER_SEPARATOR}")
2243
+
2244
+ if not (is_hostname(hostname=host) or is_ipv4_address(address=host)):
2245
+ config_failures.append("Invalid hostname or ipv4 address")
2246
+ if not username:
2247
+ config_failures.append("Username must not be empty")
2248
+ if not password:
2249
+ config_failures.append("Password is required")
2250
+ if not check_password(password=password):
2251
+ config_failures.append("Password is not valid")
2252
+ try:
2253
+ check_or_create_directory(directory=storage_directory)
2254
+ except BaseHomematicException as bhexc:
2255
+ config_failures.append(extract_exc_args(exc=bhexc)[0])
2256
+ if callback_host and not (is_hostname(hostname=callback_host) or is_ipv4_address(address=callback_host)):
2257
+ config_failures.append("Invalid callback hostname or ipv4 address")
2258
+ if callback_port_xml_rpc and not is_port(port=callback_port_xml_rpc):
2259
+ config_failures.append("Invalid xml rpc callback port")
2260
+ if json_port and not is_port(port=json_port):
2261
+ config_failures.append("Invalid json port")
2262
+ if interface_configs and not _has_primary_client(interface_configs=interface_configs):
2263
+ config_failures.append(f"No primary interface ({', '.join(PRIMARY_CLIENT_CANDIDATE_INTERFACES)}) defined")
2264
+
2265
+ return config_failures
2266
+
2267
+
2268
+ def _has_primary_client(*, interface_configs: AbstractSet[hmcl.InterfaceConfig]) -> bool:
2269
+ """Check if all configured clients exists in central."""
2270
+ for interface_config in interface_configs:
2271
+ if interface_config.interface in PRIMARY_CLIENT_CANDIDATE_INTERFACES:
2272
+ return True
2273
+ return False
2274
+
2275
+
2222
2276
  def _get_new_data_points(
2223
2277
  *,
2224
2278
  new_devices: set[Device],
@@ -58,8 +58,7 @@ from aiohomematic import central as hmcu
58
58
  from aiohomematic.client.json_rpc import AioJsonRpcAioHttpClient
59
59
  from aiohomematic.client.rpc_proxy import AioXmlRpcProxy, BaseRpcProxy
60
60
  from aiohomematic.const import (
61
- CALLBACK_WARN_ARM_INTERVAL,
62
- CALLBACK_WARN_DISARM_INTERVAL,
61
+ CALLBACK_WARN_INTERVAL,
63
62
  DATETIME_FORMAT_MILLIS,
64
63
  DEFAULT_MAX_WORKERS,
65
64
  DP_KEY_VALUE,
@@ -146,6 +145,7 @@ class Client(ABC, LogContextMixin):
146
145
  self._last_value_send_cache = CommandCache(interface_id=client_config.interface_id)
147
146
  self._available: bool = True
148
147
  self._connection_error_count: int = 0
148
+ self._is_callback_alive: bool = True
149
149
  self._is_initialized: bool = False
150
150
  self._ping_pong_cache: Final = PingPongCache(
151
151
  central=client_config.central, interface_id=client_config.interface_id
@@ -395,7 +395,7 @@ class Client(ABC, LogContextMixin):
395
395
  return False
396
396
  if not self.supports_push_updates:
397
397
  return True
398
- return (datetime.now() - self.modified_at).total_seconds() < CALLBACK_WARN_ARM_INTERVAL
398
+ return (datetime.now() - self.modified_at).total_seconds() < CALLBACK_WARN_INTERVAL
399
399
 
400
400
  def is_callback_alive(self) -> bool:
401
401
  """Return if XmlRPC-Server is alive based on received events for this client."""
@@ -404,29 +404,31 @@ class Client(ABC, LogContextMixin):
404
404
  if (
405
405
  last_events_dt := self.central.get_last_event_seen_for_interface(interface_id=self.interface_id)
406
406
  ) is not None:
407
- if (
408
- seconds_since_last_event := (datetime.now() - last_events_dt).total_seconds()
409
- ) > CALLBACK_WARN_ARM_INTERVAL:
410
- self.central.fire_interface_event(
411
- interface_id=self.interface_id,
412
- interface_event_type=InterfaceEventType.CALLBACK,
413
- data={
414
- EventKey.AVAILABLE: False,
415
- EventKey.SECONDS_SINCE_LAST_EVENT: int(seconds_since_last_event),
416
- },
417
- )
407
+ if (seconds_since_last_event := (datetime.now() - last_events_dt).total_seconds()) > CALLBACK_WARN_INTERVAL:
408
+ if self._is_callback_alive:
409
+ self.central.fire_interface_event(
410
+ interface_id=self.interface_id,
411
+ interface_event_type=InterfaceEventType.CALLBACK,
412
+ data={
413
+ EventKey.AVAILABLE: False,
414
+ EventKey.SECONDS_SINCE_LAST_EVENT: int(seconds_since_last_event),
415
+ },
416
+ )
417
+ self._is_callback_alive = False
418
418
  _LOGGER.warning(
419
419
  "IS_CALLBACK_ALIVE: Callback for %s has not received events for %is",
420
420
  self.interface_id,
421
421
  seconds_since_last_event,
422
422
  )
423
423
  return False
424
- if ((datetime.now() - last_events_dt).total_seconds()) < CALLBACK_WARN_DISARM_INTERVAL:
424
+
425
+ if not self._is_callback_alive:
425
426
  self.central.fire_interface_event(
426
427
  interface_id=self.interface_id,
427
428
  interface_event_type=InterfaceEventType.CALLBACK,
428
429
  data={EventKey.AVAILABLE: True},
429
430
  )
431
+ self._is_callback_alive = True
430
432
  return True
431
433
 
432
434
  @abstractmethod
@@ -19,7 +19,7 @@ import sys
19
19
  from types import MappingProxyType
20
20
  from typing import Any, Final, NamedTuple, Required, TypeAlias, TypedDict
21
21
 
22
- VERSION: Final = "2025.10.20b"
22
+ VERSION: Final = "2025.10.22"
23
23
 
24
24
  # Detect test speedup mode via environment
25
25
  _TEST_SPEEDUP: Final = (
@@ -133,8 +133,7 @@ WAIT_FOR_CALLBACK: Final[int | None] = None
133
133
  SCHEDULER_NOT_STARTED_SLEEP: Final = 0.2 if _TEST_SPEEDUP else 10
134
134
  SCHEDULER_LOOP_SLEEP: Final = 0.2 if _TEST_SPEEDUP else 5
135
135
 
136
- CALLBACK_WARN_ARM_INTERVAL: Final = CONNECTION_CHECKER_INTERVAL * 40
137
- CALLBACK_WARN_DISARM_INTERVAL: Final = CONNECTION_CHECKER_INTERVAL * 20
136
+ CALLBACK_WARN_INTERVAL: Final = CONNECTION_CHECKER_INTERVAL * 40
138
137
 
139
138
  # Path
140
139
  PROGRAM_SET_PATH_ROOT: Final = "program/set"
@@ -304,6 +303,7 @@ class EventKey(StrEnum):
304
303
  INTERFACE_ID = "interface_id"
305
304
  MODEL = "model"
306
305
  PARAMETER = "parameter"
306
+ PONG_MISMATCH_ACCEPTABLE = "pong_mismatch_allowed"
307
307
  PONG_MISMATCH_COUNT = "pong_mismatch_count"
308
308
  SECONDS_SINCE_LAST_EVENT = "seconds_since_last_event"
309
309
  TYPE = "type"
@@ -204,12 +204,12 @@ class CustomDataPoint(BaseDataPoint):
204
204
  def _init_data_points(self) -> None:
205
205
  """Init data point collection."""
206
206
  # Add repeating fields
207
- for field_name, parameter in self._device_def.get(hmed.CDPD.REPEATABLE_FIELDS, {}).items():
207
+ for field_name, parameter in self._device_def.get(CDPD.REPEATABLE_FIELDS, {}).items():
208
208
  if dp := self._device.get_generic_data_point(channel_address=self._channel.address, parameter=parameter):
209
209
  self._add_data_point(field=field_name, data_point=dp, is_visible=False)
210
210
 
211
211
  # Add visible repeating fields
212
- for field_name, parameter in self._device_def.get(hmed.CDPD.VISIBLE_REPEATABLE_FIELDS, {}).items():
212
+ for field_name, parameter in self._device_def.get(CDPD.VISIBLE_REPEATABLE_FIELDS, {}).items():
213
213
  if dp := self._device.get_generic_data_point(channel_address=self._channel.address, parameter=parameter):
214
214
  self._add_data_point(field=field_name, data_point=dp, is_visible=True)
215
215
 
@@ -229,11 +229,11 @@ class CustomDataPoint(BaseDataPoint):
229
229
 
230
230
  # Add device fields
231
231
  self._add_data_points(
232
- field_dict_name=hmed.CDPD.FIELDS,
232
+ field_dict_name=CDPD.FIELDS,
233
233
  )
234
234
  # Add visible device fields
235
235
  self._add_data_points(
236
- field_dict_name=hmed.CDPD.VISIBLE_FIELDS,
236
+ field_dict_name=CDPD.VISIBLE_FIELDS,
237
237
  is_visible=True,
238
238
  )
239
239
 
@@ -243,7 +243,7 @@ class CustomDataPoint(BaseDataPoint):
243
243
  if hmed.get_include_default_data_points(device_profile=self._device_profile):
244
244
  self._mark_data_points(custom_data_point_def=hmed.get_default_data_points())
245
245
 
246
- def _add_data_points(self, *, field_dict_name: hmed.CDPD, is_visible: bool | None = None) -> None:
246
+ def _add_data_points(self, *, field_dict_name: CDPD, is_visible: bool | None = None) -> None:
247
247
  """Add data points to custom data point."""
248
248
  fields = self._device_def.get(field_dict_name, {})
249
249
  for channel_no, channel in fields.items():
@@ -32,7 +32,7 @@ from aiohomematic.const import (
32
32
  ParameterType,
33
33
  )
34
34
  from aiohomematic.model import device as hmd
35
- from aiohomematic.model.custom import definition as hmed
35
+ from aiohomematic.model.custom.const import CDPD
36
36
  from aiohomematic.support import to_bool
37
37
 
38
38
  __all__ = [
@@ -565,7 +565,7 @@ def check_channel_is_the_only_primary_channel(
565
565
  device_has_multiple_channels: bool,
566
566
  ) -> bool:
567
567
  """Check if this channel is the only primary channel."""
568
- primary_channel: int = device_def[hmed.CDPD.PRIMARY_CHANNEL]
568
+ primary_channel: int = device_def[CDPD.PRIMARY_CHANNEL]
569
569
  return bool(primary_channel == current_channel_no and device_has_multiple_channels is False)
570
570
 
571
571
 
@@ -387,36 +387,17 @@ class PingPongCache:
387
387
  self._unknown_pong_logged: bool = False
388
388
 
389
389
  @property
390
- def high_pending_pongs(self) -> bool:
391
- """Check, if store contains too many pending pongs."""
392
- self._cleanup_pending_pongs()
393
- return len(self._pending_pongs) > self._allowed_delta
394
-
395
- @property
396
- def high_unknown_pongs(self) -> bool:
397
- """Check, if store contains too many unknown pongs."""
398
- self._cleanup_unknown_pongs()
399
- return len(self._unknown_pongs) > self._allowed_delta
400
-
401
- @property
402
- def low_pending_pongs(self) -> bool:
403
- """Return True when pending pong count is at or below the allowed delta (i.e., not high)."""
404
- self._cleanup_pending_pongs()
405
- return len(self._pending_pongs) <= self._allowed_delta
406
-
407
- @property
408
- def low_unknown_pongs(self) -> bool:
409
- """Return True when unknown pong count is at or below the allowed delta (i.e., not high)."""
410
- self._cleanup_unknown_pongs()
411
- return len(self._unknown_pongs) <= self._allowed_delta
390
+ def allowed_delta(self) -> int:
391
+ """Return the allowed delta."""
392
+ return self._allowed_delta
412
393
 
413
394
  @property
414
- def pending_pong_count(self) -> int:
395
+ def _pending_pong_count(self) -> int:
415
396
  """Return the pending pong count."""
416
397
  return len(self._pending_pongs)
417
398
 
418
399
  @property
419
- def unknown_pong_count(self) -> int:
400
+ def _unknown_pong_count(self) -> int:
420
401
  """Return the unknown pong count."""
421
402
  return len(self._unknown_pongs)
422
403
 
@@ -430,18 +411,16 @@ class PingPongCache:
430
411
  def handle_send_ping(self, *, ping_ts: datetime) -> None:
431
412
  """Handle send ping timestamp."""
432
413
  self._pending_pongs.add(ping_ts)
414
+ self._cleanup_pending_pongs()
433
415
  # Throttle event emission to every second ping to avoid spamming callbacks,
434
416
  # but always emit when crossing the high threshold.
435
- count = self.pending_pong_count
417
+ count = self._pending_pong_count
436
418
  if (count > self._allowed_delta) or (count % 2 == 0):
437
- self._check_and_fire_pong_event(
438
- event_type=InterfaceEventType.PENDING_PONG,
439
- pong_mismatch_count=count,
440
- )
419
+ self._check_and_fire_pong_event(event_type=InterfaceEventType.PENDING_PONG)
441
420
  _LOGGER.debug(
442
421
  "PING PONG CACHE: Increase pending PING count: %s - %i for ts: %s",
443
422
  self._interface_id,
444
- self.pending_pong_count,
423
+ count,
445
424
  ping_ts,
446
425
  )
447
426
 
@@ -449,59 +428,56 @@ class PingPongCache:
449
428
  """Handle received pong timestamp."""
450
429
  if pong_ts in self._pending_pongs:
451
430
  self._pending_pongs.remove(pong_ts)
452
- self._check_and_fire_pong_event(
453
- event_type=InterfaceEventType.PENDING_PONG,
454
- pong_mismatch_count=self.pending_pong_count,
455
- )
431
+ self._cleanup_pending_pongs()
432
+ count = self._pending_pong_count
433
+ self._check_and_fire_pong_event(event_type=InterfaceEventType.PENDING_PONG)
456
434
  _LOGGER.debug(
457
435
  "PING PONG CACHE: Reduce pending PING count: %s - %i for ts: %s",
458
436
  self._interface_id,
459
- self.pending_pong_count,
437
+ count,
438
+ pong_ts,
439
+ )
440
+ else:
441
+ self._unknown_pongs.add(pong_ts)
442
+ self._cleanup_unknown_pongs()
443
+ count = self._unknown_pong_count
444
+ self._check_and_fire_pong_event(event_type=InterfaceEventType.UNKNOWN_PONG)
445
+ _LOGGER.debug(
446
+ "PING PONG CACHE: Increase unknown PONG count: %s - %i for ts: %s",
447
+ self._interface_id,
448
+ count,
460
449
  pong_ts,
461
450
  )
462
- return
463
-
464
- self._unknown_pongs.add(pong_ts)
465
- self._check_and_fire_pong_event(
466
- event_type=InterfaceEventType.UNKNOWN_PONG,
467
- pong_mismatch_count=self.unknown_pong_count,
468
- )
469
- _LOGGER.debug(
470
- "PING PONG CACHE: Increase unknown PONG count: %s - %i for ts: %s",
471
- self._interface_id,
472
- self.unknown_pong_count,
473
- pong_ts,
474
- )
475
451
 
476
452
  def _cleanup_pending_pongs(self) -> None:
477
453
  """Cleanup too old pending pongs."""
478
454
  dt_now = datetime.now()
479
- for pong_ts in list(self._pending_pongs):
455
+ for pp_pong_ts in list(self._pending_pongs):
480
456
  # Only expire entries that are actually older than the TTL.
481
- if (dt_now - pong_ts).total_seconds() > self._ttl:
482
- self._pending_pongs.remove(pong_ts)
457
+ if (dt_now - pp_pong_ts).total_seconds() > self._ttl:
458
+ self._pending_pongs.remove(pp_pong_ts)
483
459
  _LOGGER.debug(
484
460
  "PING PONG CACHE: Removing expired pending PONG: %s - %i for ts: %s",
485
461
  self._interface_id,
486
- self.pending_pong_count,
487
- pong_ts,
462
+ self._pending_pong_count,
463
+ pp_pong_ts,
488
464
  )
489
465
 
490
466
  def _cleanup_unknown_pongs(self) -> None:
491
467
  """Cleanup too old unknown pongs."""
492
468
  dt_now = datetime.now()
493
- for pong_ts in list(self._unknown_pongs):
469
+ for up_pong_ts in list(self._unknown_pongs):
494
470
  # Only expire entries that are actually older than the TTL.
495
- if (dt_now - pong_ts).total_seconds() > self._ttl:
496
- self._unknown_pongs.remove(pong_ts)
471
+ if (dt_now - up_pong_ts).total_seconds() > self._ttl:
472
+ self._unknown_pongs.remove(up_pong_ts)
497
473
  _LOGGER.debug(
498
474
  "PING PONG CACHE: Removing expired unknown PONG: %s - %i or ts: %s",
499
475
  self._interface_id,
500
- self.unknown_pong_count,
501
- pong_ts,
476
+ self._unknown_pong_count,
477
+ up_pong_ts,
502
478
  )
503
479
 
504
- def _check_and_fire_pong_event(self, *, event_type: InterfaceEventType, pong_mismatch_count: int) -> None:
480
+ def _check_and_fire_pong_event(self, *, event_type: InterfaceEventType) -> None:
505
481
  """Fire an event about the pong status."""
506
482
 
507
483
  def _fire_event(mismatch_count: int) -> None:
@@ -515,6 +491,7 @@ class PingPongCache:
515
491
  EventKey.TYPE: event_type,
516
492
  EventKey.DATA: {
517
493
  EventKey.CENTRAL_NAME: self._central.name,
494
+ EventKey.PONG_MISMATCH_ACCEPTABLE: mismatch_count <= self._allowed_delta,
518
495
  EventKey.PONG_MISMATCH_COUNT: mismatch_count,
519
496
  },
520
497
  }
@@ -522,44 +499,46 @@ class PingPongCache:
522
499
  ),
523
500
  )
524
501
 
525
- if self.low_pending_pongs and event_type == InterfaceEventType.PENDING_PONG:
502
+ if event_type == InterfaceEventType.PENDING_PONG:
503
+ self._cleanup_pending_pongs()
504
+ count = self._pending_pong_count
505
+ if self._pending_pong_count > self._allowed_delta:
506
+ # Emit interface event to inform subscribers about high pending pong count.
507
+ _fire_event(mismatch_count=count)
508
+ if self._pending_pong_logged is False:
509
+ _LOGGER.warning(
510
+ "Pending PONG mismatch: There is a mismatch between send ping events and received pong events for instance %s. "
511
+ "Possible reason 1: You are running multiple instances with the same instance name configured for this integration. "
512
+ "Re-add one instance! Otherwise this instance will not receive update events from your CCU. "
513
+ "Possible reason 2: Something is stuck on the CCU or hasn't been cleaned up. Therefore, try a CCU restart."
514
+ "Possible reason 3: Your setup is misconfigured and this instance is not able to receive events from the CCU.",
515
+ self._interface_id,
516
+ )
517
+ self._pending_pong_logged = True
526
518
  # In low state:
527
519
  # - If we previously logged a high state, emit a reset event (mismatch=0) exactly once.
528
520
  # - Otherwise, throttle emission to every second ping (even counts > 0) to avoid spamming.
529
- if self._pending_pong_logged:
521
+ elif self._pending_pong_logged:
530
522
  _fire_event(mismatch_count=0)
531
523
  self._pending_pong_logged = False
532
- return
533
- if pong_mismatch_count > 0 and pong_mismatch_count % 2 == 0:
534
- _fire_event(mismatch_count=pong_mismatch_count)
535
- return
536
-
537
- if self.low_unknown_pongs and event_type == InterfaceEventType.UNKNOWN_PONG:
538
- # For unknown pongs, only reset the logged flag when we drop below the threshold.
539
- # We do not emit an event here since there is no explicit expectation for a reset notification.
540
- self._unknown_pong_logged = False
541
- return
542
-
543
- if self.high_pending_pongs and event_type == InterfaceEventType.PENDING_PONG:
544
- _fire_event(mismatch_count=pong_mismatch_count)
545
- if self._pending_pong_logged is False:
546
- _LOGGER.warning(
547
- "Pending PONG mismatch: There is a mismatch between send ping events and received pong events for instance %s. "
548
- "Possible reason 1: You are running multiple instances with the same instance name configured for this integration. "
549
- "Re-add one instance! Otherwise this instance will not receive update events from your CCU. "
550
- "Possible reason 2: Something is stuck on the CCU or hasn't been cleaned up. Therefore, try a CCU restart."
551
- "Possible reason 3: Your setup is misconfigured and this instance is not able to receive events from the CCU.",
552
- self._interface_id,
553
- )
554
- self._pending_pong_logged = True
555
-
556
- if self.high_unknown_pongs and event_type == InterfaceEventType.UNKNOWN_PONG:
557
- if self._unknown_pong_logged is False:
558
- _LOGGER.warning(
559
- "Unknown PONG Mismatch: Your instance %s receives PONG events, that it hasn't send. "
560
- "Possible reason 1: You are running multiple instances with the same instance name configured for this integration. "
561
- "Re-add one instance! Otherwise the other instance will not receive update events from your CCU. "
562
- "Possible reason 2: Something is stuck on the CCU or hasn't been cleaned up. Therefore, try a CCU restart.",
563
- self._interface_id,
564
- )
565
- self._unknown_pong_logged = True
524
+ elif count > 0 and count % 2 == 0:
525
+ _fire_event(mismatch_count=count)
526
+ elif event_type == InterfaceEventType.UNKNOWN_PONG:
527
+ self._cleanup_unknown_pongs()
528
+ count = self._unknown_pong_count
529
+ if self._unknown_pong_count > self._allowed_delta:
530
+ # Emit interface event to inform subscribers about high unknown pong count.
531
+ _fire_event(mismatch_count=count)
532
+ if self._unknown_pong_logged is False:
533
+ _LOGGER.warning(
534
+ "Unknown PONG Mismatch: Your instance %s receives PONG events, that it hasn't send. "
535
+ "Possible reason 1: You are running multiple instances with the same instance name configured for this integration. "
536
+ "Re-add one instance! Otherwise the other instance will not receive update events from your CCU. "
537
+ "Possible reason 2: Something is stuck on the CCU or hasn't been cleaned up. Therefore, try a CCU restart.",
538
+ self._interface_id,
539
+ )
540
+ self._unknown_pong_logged = True
541
+ else:
542
+ # For unknown pongs, only reset the logged flag when we drop below the threshold.
543
+ # We do not emit an event here since there is no explicit expectation for a reset notification.
544
+ self._unknown_pong_logged = False
@@ -317,7 +317,7 @@ _IGNORE_PARAMETERS_BY_DEVICE: Final[Mapping[Parameter, frozenset[TModelName]]] =
317
317
  "HmIP-WGT",
318
318
  }
319
319
  ),
320
- Parameter.VALVE_STATE: frozenset({"HmIPW-FALMOT-C12", "HmIP-FALMOT-C12"}),
320
+ Parameter.VALVE_STATE: frozenset({"HmIP-FALMOT-C8", "HmIPW-FALMOT-C12", "HmIP-FALMOT-C12"}),
321
321
  }
322
322
 
323
323
  _IGNORE_PARAMETERS_BY_DEVICE_LOWER: Final[dict[TParameterName, frozenset[TModelName]]] = {
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  import base64
12
12
  from collections import defaultdict
13
- from collections.abc import Callable, Collection, Mapping, Set as AbstractSet
13
+ from collections.abc import Callable, Collection, Mapping
14
14
  import contextlib
15
15
  from dataclasses import dataclass
16
16
  from datetime import datetime
@@ -30,7 +30,6 @@ from typing import Any, Final, cast
30
30
 
31
31
  import orjson
32
32
 
33
- from aiohomematic import client as hmcl
34
33
  from aiohomematic.const import (
35
34
  ADDRESS_SEPARATOR,
36
35
  ALLOWED_HOSTNAME_PATTERN,
@@ -38,12 +37,10 @@ from aiohomematic.const import (
38
37
  CHANNEL_ADDRESS_PATTERN,
39
38
  DEVICE_ADDRESS_PATTERN,
40
39
  HTMLTAG_PATTERN,
41
- IDENTIFIER_SEPARATOR,
42
40
  INIT_DATETIME,
43
41
  ISO_8859_1,
44
42
  MAX_CACHE_AGE,
45
43
  NO_CACHE_ENTRY,
46
- PRIMARY_CLIENT_CANDIDATE_INTERFACES,
47
44
  TIMEOUT,
48
45
  UTF_8,
49
46
  CommandRxMode,
@@ -95,55 +92,6 @@ def build_xml_rpc_headers(
95
92
  return [("Authorization", f"Basic {base64_message}")]
96
93
 
97
94
 
98
- def check_config(
99
- *,
100
- central_name: str,
101
- host: str,
102
- username: str,
103
- password: str,
104
- storage_directory: str,
105
- callback_host: str | None,
106
- callback_port_xml_rpc: int | None,
107
- json_port: int | None,
108
- interface_configs: AbstractSet[hmcl.InterfaceConfig] | None = None,
109
- ) -> list[str]:
110
- """Check config. Throws BaseHomematicException on failure."""
111
- config_failures: list[str] = []
112
- if central_name and IDENTIFIER_SEPARATOR in central_name:
113
- config_failures.append(f"Instance name must not contain {IDENTIFIER_SEPARATOR}")
114
-
115
- if not (is_hostname(hostname=host) or is_ipv4_address(address=host)):
116
- config_failures.append("Invalid hostname or ipv4 address")
117
- if not username:
118
- config_failures.append("Username must not be empty")
119
- if not password:
120
- config_failures.append("Password is required")
121
- if not check_password(password=password):
122
- config_failures.append("Password is not valid")
123
- try:
124
- check_or_create_directory(directory=storage_directory)
125
- except BaseHomematicException as bhexc:
126
- config_failures.append(extract_exc_args(exc=bhexc)[0])
127
- if callback_host and not (is_hostname(hostname=callback_host) or is_ipv4_address(address=callback_host)):
128
- config_failures.append("Invalid callback hostname or ipv4 address")
129
- if callback_port_xml_rpc and not is_port(port=callback_port_xml_rpc):
130
- config_failures.append("Invalid xml rpc callback port")
131
- if json_port and not is_port(port=json_port):
132
- config_failures.append("Invalid json port")
133
- if interface_configs and not has_primary_client(interface_configs=interface_configs):
134
- config_failures.append(f"No primary interface ({', '.join(PRIMARY_CLIENT_CANDIDATE_INTERFACES)}) defined")
135
-
136
- return config_failures
137
-
138
-
139
- def has_primary_client(*, interface_configs: AbstractSet[hmcl.InterfaceConfig]) -> bool:
140
- """Check if all configured clients exists in central."""
141
- for interface_config in interface_configs:
142
- if interface_config.interface in PRIMARY_CLIENT_CANDIDATE_INTERFACES:
143
- return True
144
- return False
145
-
146
-
147
95
  def delete_file(directory: str, file_name: str) -> None: # kwonly: disable
148
96
  """Delete the file. File can contain a wildcard."""
149
97
  if os.path.exists(directory):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiohomematic
3
- Version: 2025.10.20b0
3
+ Version: 2025.10.22
4
4
  Summary: Homematic interface for Home Assistant running on Python 3.
5
5
  Home-page: https://github.com/sukramj/aiohomematic
6
6
  Author-email: SukramJ <sukramj@icloud.com>, Daniel Perna <danielperna84@gmail.com>
@@ -126,6 +126,7 @@ disable = [
126
126
  "cyclic-import",
127
127
  "duplicate-code",
128
128
  "inconsistent-return-statements",
129
+ "import-outside-toplevel", # C0415
129
130
  "locally-disabled",
130
131
  "not-context-manager",
131
132
  "too-few-public-methods",