aiohomematic 2025.10.9__tar.gz → 2025.10.10__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 (114) hide show
  1. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/PKG-INFO +1 -1
  2. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/central/__init__.py +20 -9
  3. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/client/__init__.py +4 -2
  4. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/const.py +18 -9
  5. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/custom/climate.py +4 -4
  6. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/data_point.py +3 -4
  7. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/device.py +5 -5
  8. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/update.py +2 -2
  9. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/store/persistent.py +98 -61
  10. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic.egg-info/PKG-INFO +1 -1
  11. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic.egg-info/SOURCES.txt +1 -0
  12. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic_support/client_local.py +6 -6
  13. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_central.py +1 -1
  14. aiohomematic-2025.10.10/tests/test_session_recorder.py +38 -0
  15. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/LICENSE +0 -0
  16. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/README.md +0 -0
  17. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/__init__.py +0 -0
  18. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/async_support.py +0 -0
  19. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/central/decorators.py +0 -0
  20. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/central/rpc_server.py +0 -0
  21. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/client/_rpc_errors.py +0 -0
  22. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/client/json_rpc.py +0 -0
  23. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/client/rpc_proxy.py +0 -0
  24. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/context.py +0 -0
  25. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/converter.py +0 -0
  26. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/decorators.py +0 -0
  27. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/exceptions.py +0 -0
  28. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/hmcli.py +0 -0
  29. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/__init__.py +0 -0
  30. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/calculated/__init__.py +0 -0
  31. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/calculated/climate.py +0 -0
  32. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/calculated/data_point.py +0 -0
  33. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/calculated/operating_voltage_level.py +0 -0
  34. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/calculated/support.py +0 -0
  35. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/custom/__init__.py +0 -0
  36. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/custom/const.py +0 -0
  37. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/custom/cover.py +0 -0
  38. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/custom/data_point.py +0 -0
  39. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/custom/definition.py +0 -0
  40. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/custom/light.py +0 -0
  41. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/custom/lock.py +0 -0
  42. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/custom/siren.py +0 -0
  43. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/custom/support.py +0 -0
  44. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/custom/switch.py +0 -0
  45. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/custom/valve.py +0 -0
  46. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/event.py +0 -0
  47. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/generic/__init__.py +0 -0
  48. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/generic/action.py +0 -0
  49. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/generic/binary_sensor.py +0 -0
  50. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/generic/button.py +0 -0
  51. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/generic/data_point.py +0 -0
  52. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/generic/number.py +0 -0
  53. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/generic/select.py +0 -0
  54. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/generic/sensor.py +0 -0
  55. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/generic/switch.py +0 -0
  56. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/generic/text.py +0 -0
  57. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/hub/__init__.py +0 -0
  58. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/hub/binary_sensor.py +0 -0
  59. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/hub/button.py +0 -0
  60. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/hub/data_point.py +0 -0
  61. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/hub/number.py +0 -0
  62. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/hub/select.py +0 -0
  63. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/hub/sensor.py +0 -0
  64. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/hub/switch.py +0 -0
  65. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/hub/text.py +0 -0
  66. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/support.py +0 -0
  67. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/property_decorators.py +0 -0
  68. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/py.typed +0 -0
  69. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/rega_scripts/fetch_all_device_data.fn +0 -0
  70. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/rega_scripts/get_program_descriptions.fn +0 -0
  71. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/rega_scripts/get_serial.fn +0 -0
  72. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/rega_scripts/get_system_variable_descriptions.fn +0 -0
  73. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/rega_scripts/set_program_state.fn +0 -0
  74. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/rega_scripts/set_system_variable.fn +0 -0
  75. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/store/__init__.py +0 -0
  76. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/store/dynamic.py +0 -0
  77. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/store/visibility.py +0 -0
  78. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/support.py +0 -0
  79. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/validator.py +0 -0
  80. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic.egg-info/dependency_links.txt +0 -0
  81. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic.egg-info/requires.txt +0 -0
  82. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic.egg-info/top_level.txt +0 -0
  83. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic_support/__init__.py +0 -0
  84. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/pyproject.toml +0 -0
  85. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/setup.cfg +0 -0
  86. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_action.py +0 -0
  87. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_async_support.py +0 -0
  88. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_binary_sensor.py +0 -0
  89. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_button.py +0 -0
  90. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_calculated_support.py +0 -0
  91. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_central_pydevccu.py +0 -0
  92. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_climate.py +0 -0
  93. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_cover.py +0 -0
  94. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_decorator.py +0 -0
  95. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_device.py +0 -0
  96. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_dynamic_caches.py +0 -0
  97. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_entity.py +0 -0
  98. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_event.py +0 -0
  99. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_json_rpc.py +0 -0
  100. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_json_rpc_client_integration.py +0 -0
  101. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_kwonly_lint.py +0 -0
  102. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_light.py +0 -0
  103. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_lock.py +0 -0
  104. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_logging_support.py +0 -0
  105. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_number.py +0 -0
  106. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_select.py +0 -0
  107. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_sensor.py +0 -0
  108. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_siren.py +0 -0
  109. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_support.py +0 -0
  110. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_support_extra.py +0 -0
  111. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_switch.py +0 -0
  112. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_text.py +0 -0
  113. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_valve.py +0 -0
  114. {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_xml_rpc_proxy_integration.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiohomematic
3
- Version: 2025.10.9
3
+ Version: 2025.10.10
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>
@@ -96,8 +96,10 @@ from aiohomematic.const import (
96
96
  DEFAULT_IGNORE_CUSTOM_DEVICE_DEFINITION_MODELS,
97
97
  DEFAULT_INTERFACES_REQUIRING_PERIODIC_REFRESH,
98
98
  DEFAULT_MAX_READ_WORKERS,
99
+ DEFAULT_OPTIONAL_SETTINGS,
99
100
  DEFAULT_PERIODIC_REFRESH_INTERVAL,
100
101
  DEFAULT_PROGRAM_MARKERS,
102
+ DEFAULT_SESSION_RECORDER_START_FOR_SECONDS,
101
103
  DEFAULT_STORAGE_DIRECTORY,
102
104
  DEFAULT_SYS_SCAN_INTERVAL,
103
105
  DEFAULT_SYSVAR_MARKERS,
@@ -130,6 +132,7 @@ from aiohomematic.const import (
130
132
  Interface,
131
133
  InterfaceEventType,
132
134
  Operations,
135
+ OptionalSettings,
133
136
  Parameter,
134
137
  ParamsetKey,
135
138
  ProxyInitState,
@@ -223,7 +226,7 @@ class CentralUnit(LogContextMixin, PayloadMixin):
223
226
  self._paramset_descriptions: Final = ParamsetDescriptionCache(central=self)
224
227
  self._parameter_visibility: Final = ParameterVisibilityCache(central=self)
225
228
  self._recorder: Final = SessionRecorder(
226
- central=self, default_ttl_seconds=600, active=central_config.start_recorder
229
+ central=self, ttl_seconds=600, active=central_config.session_recorder_start
227
230
  )
228
231
  self._primary_client: hmcl.Client | None = None
229
232
  # {interface_id, client}
@@ -493,14 +496,14 @@ class CentralUnit(LogContextMixin, PayloadMixin):
493
496
  _LOGGER.debug("START: Central %s already started", self.name)
494
497
  return
495
498
 
496
- if self._config.start_recorder:
499
+ if self._config.session_recorder_start:
497
500
  await self._recorder.deactivate(
498
- delay=self._config.start_recorder_for_minutes * 60,
501
+ delay=self._config.session_recorder_start_for_seconds,
499
502
  auto_save=True,
500
- randomize_output=True,
501
- use_ts_in_filename=False,
503
+ randomize_output=self._config.session_recorder_randomize_output,
504
+ use_ts_in_file_name=False,
502
505
  )
503
- _LOGGER.debug("START: Starting Recorder for %s minutes", self._config.start_recorder_for_minutes)
506
+ _LOGGER.debug("START: Starting Recorder for %s seconds", self._config.session_recorder_start_for_seconds)
504
507
 
505
508
  self._state = CentralUnitState.INITIALIZING
506
509
  _LOGGER.debug("START: Initializing Central %s", self.name)
@@ -2009,10 +2012,10 @@ class CentralConfig:
2009
2012
  listen_ip_addr: str | None = None,
2010
2013
  listen_port_xml_rpc: int | None = None,
2011
2014
  max_read_workers: int = DEFAULT_MAX_READ_WORKERS,
2015
+ optional_settings: tuple[OptionalSettings | str, ...] = DEFAULT_OPTIONAL_SETTINGS,
2012
2016
  periodic_refresh_interval: int = DEFAULT_PERIODIC_REFRESH_INTERVAL,
2013
2017
  program_markers: tuple[DescriptionMarker | str, ...] = DEFAULT_PROGRAM_MARKERS,
2014
2018
  start_direct: bool = False,
2015
- start_recorder_for_minutes: int = 0,
2016
2019
  storage_directory: str = DEFAULT_STORAGE_DIRECTORY,
2017
2020
  sys_scan_interval: int = DEFAULT_SYS_SCAN_INTERVAL,
2018
2021
  sysvar_markers: tuple[DescriptionMarker | str, ...] = DEFAULT_SYSVAR_MARKERS,
@@ -2023,6 +2026,7 @@ class CentralConfig:
2023
2026
  ) -> None:
2024
2027
  """Init the client config."""
2025
2028
  self._interface_configs: Final = interface_configs
2029
+ self._optional_settings: Final = frozenset(optional_settings or ())
2026
2030
  self.requires_xml_rpc_server: Final = any(
2027
2031
  ic for ic in interface_configs if ic.rpc_server == RpcServerType.XML_RPC
2028
2032
  )
@@ -2048,8 +2052,15 @@ class CentralConfig:
2048
2052
  self.periodic_refresh_interval = periodic_refresh_interval
2049
2053
  self.program_markers: Final = program_markers
2050
2054
  self.start_direct: Final = start_direct
2051
- self.start_recorder_for_minutes: Final = start_recorder_for_minutes
2052
- self.start_recorder = start_recorder_for_minutes > 0
2055
+ self.session_recorder_randomize_output = (
2056
+ OptionalSettings.SR_DISABLE_RANDOMIZE_OUTPUT not in self._optional_settings
2057
+ )
2058
+ self.session_recorder_start_for_seconds: Final = (
2059
+ DEFAULT_SESSION_RECORDER_START_FOR_SECONDS
2060
+ if OptionalSettings.SR_RECORD_SYSTEM_INIT in self._optional_settings
2061
+ else 0
2062
+ )
2063
+ self.session_recorder_start = self.session_recorder_start_for_seconds > 0
2053
2064
  self.storage_directory: Final = storage_directory
2054
2065
  self.sys_scan_interval: Final = sys_scan_interval
2055
2066
  self.sysvar_markers: Final = sysvar_markers
@@ -60,7 +60,6 @@ from aiohomematic.client.rpc_proxy import AioXmlRpcProxy, BaseRpcProxy
60
60
  from aiohomematic.const import (
61
61
  CALLBACK_WARN_INTERVAL,
62
62
  DATETIME_FORMAT_MILLIS,
63
- DEFAULT_CUSTOM_ID,
64
63
  DEFAULT_MAX_WORKERS,
65
64
  DP_KEY_VALUE,
66
65
  DUMMY_SERIAL,
@@ -81,6 +80,7 @@ from aiohomematic.const import (
81
80
  ForcedDeviceAvailability,
82
81
  Interface,
83
82
  InterfaceEventType,
83
+ InternalCustomID,
84
84
  Operations,
85
85
  ParameterData,
86
86
  ParameterType,
@@ -1815,7 +1815,9 @@ async def _track_single_data_point_state_change_or_timeout(
1815
1815
  )
1816
1816
  return
1817
1817
  if (
1818
- unsub := dp.register_data_point_updated_callback(cb=_async_event_changed, custom_id=DEFAULT_CUSTOM_ID)
1818
+ unsub := dp.register_data_point_updated_callback(
1819
+ cb=_async_event_changed, custom_id=InternalCustomID.DEFAULT
1820
+ )
1819
1821
  ) is None:
1820
1822
  return
1821
1823
 
@@ -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.9"
22
+ VERSION: Final = "2025.10.10"
23
23
 
24
24
  # Detect test speedup mode via environment
25
25
  _TEST_SPEEDUP: Final = (
@@ -27,8 +27,6 @@ _TEST_SPEEDUP: Final = (
27
27
  )
28
28
 
29
29
  # default
30
- DEFAULT_STORAGE_DIRECTORY: Final = "aiohomematic_storage"
31
- DEFAULT_CUSTOM_ID: Final = "custom_id"
32
30
  DEFAULT_DELAY_NEW_DEVICE_CREATION: Final = False
33
31
  DEFAULT_ENABLE_DEVICE_FIRMWARE_CHECK: Final = False
34
32
  DEFAULT_ENABLE_PROGRAM_SCAN: Final = True
@@ -40,8 +38,11 @@ DEFAULT_INCLUDE_INTERNAL_SYSVARS: Final = True
40
38
  DEFAULT_MAX_READ_WORKERS: Final = 1
41
39
  DEFAULT_MAX_WORKERS: Final = 1
42
40
  DEFAULT_MULTIPLIER: Final = 1.0
41
+ DEFAULT_OPTIONAL_SETTINGS: Final[tuple[OptionalSettings | str, ...]] = ()
43
42
  DEFAULT_PERIODIC_REFRESH_INTERVAL: Final = 15
44
43
  DEFAULT_PROGRAM_MARKERS: Final[tuple[DescriptionMarker | str, ...]] = ()
44
+ DEFAULT_SESSION_RECORDER_START_FOR_SECONDS: Final = 120
45
+ DEFAULT_STORAGE_DIRECTORY: Final = "aiohomematic_storage"
45
46
  DEFAULT_SYSVAR_MARKERS: Final[tuple[DescriptionMarker | str, ...]] = ()
46
47
  DEFAULT_SYS_SCAN_INTERVAL: Final = 30
47
48
  DEFAULT_TLS: Final = False
@@ -49,12 +50,6 @@ DEFAULT_UN_IGNORES: Final[frozenset[str]] = frozenset()
49
50
  DEFAULT_USE_GROUP_CHANNEL_FOR_COVER_STATE: Final = True
50
51
  DEFAULT_VERIFY_TLS: Final = False
51
52
 
52
- MANU_TEMP_CUSTOM_ID: Final = "manu_temp"
53
- INTERNAL_CUSTOM_IDS: Final[tuple[str, ...]] = (
54
- DEFAULT_CUSTOM_ID,
55
- MANU_TEMP_CUSTOM_ID,
56
- )
57
-
58
53
  # Default encoding for json service calls, persistent cache
59
54
  UTF_8: Final = "utf-8"
60
55
  # Default encoding for xmlrpc service calls and script files
@@ -214,6 +209,13 @@ class CommandRxMode(StrEnum):
214
209
  WAKEUP = "WAKEUP"
215
210
 
216
211
 
212
+ class InternalCustomID(StrEnum):
213
+ """Enum for Homematic internal custom IDs."""
214
+
215
+ DEFAULT = "cid_default"
216
+ MANU_TEMP = "cid_manu_temp"
217
+
218
+
217
219
  class DataOperationResult(Enum):
218
220
  """Enum with data operation results."""
219
221
 
@@ -352,6 +354,13 @@ class Operations(IntEnum):
352
354
  EVENT = 4
353
355
 
354
356
 
357
+ class OptionalSettings(StrEnum):
358
+ """Enum with aiohomematic optional settings."""
359
+
360
+ SR_DISABLE_RANDOMIZE_OUTPUT = "SR_DISABLE_RANDOMIZED_OUTPUT"
361
+ SR_RECORD_SYSTEM_INIT = "SR_RECORD_SYSTEM_INIT"
362
+
363
+
355
364
  class Parameter(StrEnum):
356
365
  """Enum with Homematic parameters."""
357
366
 
@@ -12,10 +12,10 @@ import logging
12
12
  from typing import Any, Final, cast
13
13
 
14
14
  from aiohomematic.const import (
15
- MANU_TEMP_CUSTOM_ID,
16
15
  SCHEDULER_PROFILE_PATTERN,
17
16
  SCHEDULER_TIME_PATTERN,
18
17
  DataPointCategory,
18
+ InternalCustomID,
19
19
  Parameter,
20
20
  ParamsetKey,
21
21
  ProductGroup,
@@ -235,7 +235,7 @@ class BaseCustomDpClimate(CustomDataPoint):
235
235
  )
236
236
  self._unregister_callbacks.append(
237
237
  self._dp_setpoint.register_data_point_updated_callback(
238
- cb=self._manu_temp_changed, custom_id=MANU_TEMP_CUSTOM_ID
238
+ cb=self._manu_temp_changed, custom_id=InternalCustomID.MANU_TEMP
239
239
  )
240
240
  )
241
241
 
@@ -802,7 +802,7 @@ class CustomDpRfThermostat(BaseCustomDpClimate):
802
802
 
803
803
  self._unregister_callbacks.append(
804
804
  self._dp_control_mode.register_data_point_updated_callback(
805
- cb=self._manu_temp_changed, custom_id=MANU_TEMP_CUSTOM_ID
805
+ cb=self._manu_temp_changed, custom_id=InternalCustomID.MANU_TEMP
806
806
  )
807
807
  )
808
808
 
@@ -1047,7 +1047,7 @@ class CustomDpIpThermostat(BaseCustomDpClimate):
1047
1047
 
1048
1048
  self._unregister_callbacks.append(
1049
1049
  self._dp_set_point_mode.register_data_point_updated_callback(
1050
- cb=self._manu_temp_changed, custom_id=MANU_TEMP_CUSTOM_ID
1050
+ cb=self._manu_temp_changed, custom_id=InternalCustomID.MANU_TEMP
1051
1051
  )
1052
1052
  )
1053
1053
 
@@ -39,11 +39,9 @@ from aiohomematic import central as hmcu, client as hmcl, support as hms, valida
39
39
  from aiohomematic.async_support import loop_check
40
40
  from aiohomematic.const import (
41
41
  CALLBACK_TYPE,
42
- DEFAULT_CUSTOM_ID,
43
42
  DEFAULT_MULTIPLIER,
44
43
  DP_KEY_VALUE,
45
44
  INIT_DATETIME,
46
- INTERNAL_CUSTOM_IDS,
47
45
  KEY_CHANNEL_OPERATION_MODE_VISIBILITY,
48
46
  KWARGS_ARG_CUSTOM_ID,
49
47
  KWARGS_ARG_DATA_POINT,
@@ -55,6 +53,7 @@ from aiohomematic.const import (
55
53
  DataPointUsage,
56
54
  EventKey,
57
55
  Flag,
56
+ InternalCustomID,
58
57
  Operations,
59
58
  Parameter,
60
59
  ParameterData,
@@ -309,11 +308,11 @@ class CallbackDataPoint(ABC, LogContextMixin):
309
308
 
310
309
  def register_internal_data_point_updated_callback(self, *, cb: Callable) -> CALLBACK_TYPE:
311
310
  """Register internal data_point updated callback."""
312
- return self.register_data_point_updated_callback(cb=cb, custom_id=DEFAULT_CUSTOM_ID)
311
+ return self.register_data_point_updated_callback(cb=cb, custom_id=InternalCustomID.DEFAULT)
313
312
 
314
313
  def register_data_point_updated_callback(self, *, cb: Callable, custom_id: str) -> CALLBACK_TYPE:
315
314
  """Register data_point updated callback."""
316
- if custom_id not in INTERNAL_CUSTOM_IDS:
315
+ if custom_id not in InternalCustomID:
317
316
  if self._custom_id is not None and self._custom_id != custom_id:
318
317
  raise AioHomematicException(
319
318
  f"REGISTER_data_point_updated_CALLBACK failed: hm_data_point: {self.full_name} is already registered by {self._custom_id}"
@@ -1293,7 +1293,7 @@ class _DefinitionExporter:
1293
1293
  str, dict[ParamsetKey, dict[str, ParameterData]]
1294
1294
  ] = await self._client.get_all_paramset_descriptions(device_descriptions=tuple(device_descriptions.values()))
1295
1295
  model = device_descriptions[self._device_address]["TYPE"]
1296
- filename = f"{model}.json"
1296
+ file_name = f"{model}.json"
1297
1297
 
1298
1298
  # anonymize device_descriptions
1299
1299
  anonymize_device_descriptions: list[DeviceDescription] = []
@@ -1316,14 +1316,14 @@ class _DefinitionExporter:
1316
1316
  # Save device_descriptions for device to file.
1317
1317
  await self._save(
1318
1318
  directory=f"{self._storage_directory}/{DEVICE_DESCRIPTIONS_DIR}",
1319
- filename=filename,
1319
+ file_name=file_name,
1320
1320
  data=anonymize_device_descriptions,
1321
1321
  )
1322
1322
 
1323
1323
  # Save device_descriptions for device to file.
1324
1324
  await self._save(
1325
1325
  directory=f"{self._storage_directory}/{PARAMSET_DESCRIPTIONS_DIR}",
1326
- filename=filename,
1326
+ file_name=file_name,
1327
1327
  data=anonymize_paramset_descriptions,
1328
1328
  )
1329
1329
 
@@ -1332,13 +1332,13 @@ class _DefinitionExporter:
1332
1332
  address_parts[0] = self._random_id
1333
1333
  return ADDRESS_SEPARATOR.join(address_parts)
1334
1334
 
1335
- async def _save(self, *, directory: str, filename: str, data: Any) -> DataOperationResult:
1335
+ async def _save(self, *, directory: str, file_name: str, data: Any) -> DataOperationResult:
1336
1336
  """Save file to disk."""
1337
1337
 
1338
1338
  def perform_save() -> DataOperationResult:
1339
1339
  if not check_or_create_directory(directory=directory):
1340
1340
  return DataOperationResult.NO_SAVE # pragma: no cover
1341
- with open(file=os.path.join(directory, filename), mode="wb") as fptr:
1341
+ with open(file=os.path.join(directory, file_name), mode="wb") as fptr:
1342
1342
  fptr.write(orjson.dumps(data, option=orjson.OPT_INDENT_2 | orjson.OPT_NON_STR_KEYS))
1343
1343
  return DataOperationResult.SAVE_SUCCESS
1344
1344
 
@@ -11,11 +11,11 @@ from typing import Final
11
11
 
12
12
  from aiohomematic.const import (
13
13
  CALLBACK_TYPE,
14
- DEFAULT_CUSTOM_ID,
15
14
  HMIP_FIRMWARE_UPDATE_IN_PROGRESS_STATES,
16
15
  HMIP_FIRMWARE_UPDATE_READY_STATES,
17
16
  DataPointCategory,
18
17
  Interface,
18
+ InternalCustomID,
19
19
  )
20
20
  from aiohomematic.decorators import inspector
21
21
  from aiohomematic.exceptions import AioHomematicException
@@ -114,7 +114,7 @@ class DpUpdate(CallbackDataPoint, PayloadMixin):
114
114
 
115
115
  def register_data_point_updated_callback(self, *, cb: Callable, custom_id: str) -> CALLBACK_TYPE:
116
116
  """Register update callback."""
117
- if custom_id != DEFAULT_CUSTOM_ID:
117
+ if custom_id != InternalCustomID.DEFAULT:
118
118
  if self._custom_id is not None:
119
119
  raise AioHomematicException(
120
120
  f"REGISTER_UPDATE_CALLBACK failed: hm_data_point: {self.full_name} is already registered by {self._custom_id}"
@@ -27,7 +27,7 @@ Key behaviors
27
27
  - Save/load/clear operations are synchronized via a semaphore and executed via
28
28
  the CentralUnit looper to avoid blocking the event loop.
29
29
 
30
- Helper functions are provided to build content paths and filenames and to
30
+ Helper functions are provided to build content paths and file names and to
31
31
  optionally clean up stale content directories.
32
32
  """
33
33
 
@@ -89,7 +89,7 @@ class BasePersistentFile(ABC):
89
89
  "_persistent_content",
90
90
  "_save_load_semaphore",
91
91
  "_sub_directory",
92
- "_use_ts_in_filenames",
92
+ "_use_ts_in_file_names",
93
93
  "last_hash_saved",
94
94
  "last_save_triggered",
95
95
  )
@@ -123,27 +123,27 @@ class BasePersistentFile(ABC):
123
123
  """Return if the data has changed."""
124
124
  return self.content_hash != self.last_hash_saved
125
125
 
126
- def _get_filename(
126
+ def _get_file_name(
127
127
  self,
128
128
  *,
129
- use_ts_in_filename: bool = False,
129
+ use_ts_in_file_name: bool = False,
130
130
  ) -> str:
131
131
  """Return the file name."""
132
- return _get_filename(
132
+ return _get_file_name(
133
133
  central_name=self._central.name,
134
134
  file_name=self._file_postfix,
135
- ts=datetime.now() if use_ts_in_filename else None,
135
+ ts=datetime.now() if use_ts_in_file_name else None,
136
136
  )
137
137
 
138
138
  def _get_file_path(
139
139
  self,
140
140
  *,
141
- use_ts_in_filename: bool = False,
141
+ use_ts_in_file_name: bool = False,
142
142
  ) -> str:
143
143
  """Return the full file path."""
144
- return os.path.join(self._directory, self._get_filename(use_ts_in_filename=use_ts_in_filename))
144
+ return os.path.join(self._directory, self._get_file_name(use_ts_in_file_name=use_ts_in_file_name))
145
145
 
146
- async def save(self, *, randomize_output: bool = False, use_ts_in_filename: bool = False) -> DataOperationResult:
146
+ async def save(self, *, randomize_output: bool = False, use_ts_in_file_name: bool = False) -> DataOperationResult:
147
147
  """Save current data to disk."""
148
148
  if not self._should_save:
149
149
  return DataOperationResult.NO_SAVE
@@ -154,7 +154,7 @@ class BasePersistentFile(ABC):
154
154
  def _perform_save() -> DataOperationResult:
155
155
  try:
156
156
  with open(
157
- file=self._get_file_path(use_ts_in_filename=use_ts_in_filename),
157
+ file=self._get_file_path(use_ts_in_file_name=use_ts_in_file_name),
158
158
  mode="wb",
159
159
  ) as file_pointer:
160
160
  file_pointer.write(
@@ -173,7 +173,7 @@ class BasePersistentFile(ABC):
173
173
 
174
174
  async with self._save_load_semaphore:
175
175
  return await self._central.looper.async_add_executor_job(
176
- _perform_save, name=f"save-persistent-content-{self._get_filename()}"
176
+ _perform_save, name=f"save-persistent-content-{self._get_file_name()}"
177
177
  )
178
178
 
179
179
  def _manipulate_content(self, *, content: bytes, randomize_output: bool = False) -> bytes:
@@ -197,13 +197,16 @@ class BasePersistentFile(ABC):
197
197
  and self.content_hash != self.last_hash_saved
198
198
  )
199
199
 
200
- async def load(self) -> DataOperationResult:
200
+ async def load(self, *, file_path: str | None = None) -> DataOperationResult:
201
201
  """Load data from disk into the dictionary."""
202
- if not check_or_create_directory(directory=self._directory) or not os.path.exists(self._get_file_path()):
202
+ if not file_path and not check_or_create_directory(directory=self._directory):
203
+ return DataOperationResult.NO_LOAD
204
+
205
+ if (file_path := file_path or self._get_file_path()) and not os.path.exists(file_path):
203
206
  return DataOperationResult.NO_LOAD
204
207
 
205
208
  def _perform_load() -> DataOperationResult:
206
- with open(file=self._get_file_path(), encoding=UTF_8) as file_pointer:
209
+ with open(file=file_path, encoding=UTF_8) as file_pointer:
207
210
  try:
208
211
  data = json.loads(file_pointer.read(), object_hook=regular_to_default_dict_hook)
209
212
  if (converted_hash := hash_sha256(value=data)) == self.last_hash_saved:
@@ -217,7 +220,7 @@ class BasePersistentFile(ABC):
217
220
 
218
221
  async with self._save_load_semaphore:
219
222
  return await self._central.looper.async_add_executor_job(
220
- _perform_load, name=f"load-persistent-content-{self._get_filename()}"
223
+ _perform_load, name=f"load-persistent-content-{self._get_file_name()}"
221
224
  )
222
225
 
223
226
  async def clear(self) -> None:
@@ -358,12 +361,12 @@ class DeviceDescriptionCache(BasePersistentFile):
358
361
  addr_set.add(device_address)
359
362
  addr_set.add(address)
360
363
 
361
- async def load(self) -> DataOperationResult:
364
+ async def load(self, *, file_path: str | None = None) -> DataOperationResult:
362
365
  """Load device data from disk into _device_description_cache."""
363
366
  if not self._central.config.use_caches:
364
367
  _LOGGER.debug("load: not caching paramset descriptions for %s", self._central.name)
365
368
  return DataOperationResult.NO_LOAD
366
- if (result := await super().load()) == DataOperationResult.LOAD_SUCCESS:
369
+ if (result := await super().load(file_path=file_path)) == DataOperationResult.LOAD_SUCCESS:
367
370
  for (
368
371
  interface_id,
369
372
  device_descriptions,
@@ -495,12 +498,12 @@ class ParamsetDescriptionCache(BasePersistentFile):
495
498
  for parameter in paramset:
496
499
  cache.setdefault((device_address, parameter), set()).add(channel_no)
497
500
 
498
- async def load(self) -> DataOperationResult:
501
+ async def load(self, *, file_path: str | None = None) -> DataOperationResult:
499
502
  """Load paramset descriptions from disk into paramset cache."""
500
503
  if not self._central.config.use_caches:
501
504
  _LOGGER.debug("load: not caching device descriptions for %s", self._central.name)
502
505
  return DataOperationResult.NO_LOAD
503
- if (result := await super().load()) == DataOperationResult.LOAD_SUCCESS:
506
+ if (result := await super().load(file_path=file_path)) == DataOperationResult.LOAD_SUCCESS:
504
507
  self._init_address_parameter_list()
505
508
  return result
506
509
 
@@ -511,17 +514,16 @@ class SessionRecorder(BasePersistentFile):
511
514
 
512
515
  Nested cache with TTL support.
513
516
  Structure:
514
- store[rpc_type][method][params] = (ts: datetime, response: Any, ttl_s: float)
517
+ store[rpc_type][method][params][ts: datetime] = response: Any
515
518
 
516
- - Each entry expires after its TTL (global default or per-entry override).
517
519
  - Expiration is lazy (checked on access/update).
518
520
  - Optional refresh_on_get extends TTL when reading.
519
521
  """
520
522
 
521
523
  __slots__ = (
522
524
  "_active",
523
- "_default_ttl",
524
- "_is_delayed",
525
+ "_ttl",
526
+ "_is_recording",
525
527
  "_refresh_on_get",
526
528
  "_store",
527
529
  )
@@ -533,20 +535,20 @@ class SessionRecorder(BasePersistentFile):
533
535
  self,
534
536
  *,
535
537
  central: hmcu.CentralUnit,
536
- default_ttl_seconds: float,
537
538
  active: bool,
539
+ ttl_seconds: float,
538
540
  refresh_on_get: bool = False,
539
541
  ):
540
542
  """Init the cache."""
541
543
  self._active = active
542
- if default_ttl_seconds <= 0:
544
+ if ttl_seconds < 0:
543
545
  raise ValueError("default_ttl_seconds must be positive")
544
- self._default_ttl: Final = float(default_ttl_seconds)
545
- self._is_delayed: bool = False
546
+ self._ttl: Final = float(ttl_seconds)
547
+ self._is_recording: bool = False
546
548
  self._refresh_on_get: Final = refresh_on_get
547
- # Use nested defaultdicts: rpc_type -> method -> params -> ts(int) -> (response, ttl_s)
549
+ # Use nested defaultdicts: rpc_type -> method -> params -> ts(int) -> response
548
550
  # Annotate as defaultdict to match the actual type and satisfy mypy.
549
- self._store: dict[str, dict[str, dict[str, dict[int, tuple[Any, float]]]]] = defaultdict(
551
+ self._store: dict[str, dict[str, dict[str, dict[int, Any]]]] = defaultdict(
550
552
  lambda: defaultdict(lambda: defaultdict(dict))
551
553
  )
552
554
  super().__init__(
@@ -556,6 +558,13 @@ class SessionRecorder(BasePersistentFile):
556
558
 
557
559
  # ---------- internal helpers ----------
558
560
 
561
+ def _is_expired(self, *, ts: int, now: int | None = None) -> bool:
562
+ """Check whether an entry has expired given epoch seconds."""
563
+ if self._ttl == 0:
564
+ return False
565
+ now = now if now is not None else _now()
566
+ return (now - ts) > self._ttl
567
+
559
568
  def _purge_expired_at(
560
569
  self,
561
570
  *,
@@ -563,7 +572,8 @@ class SessionRecorder(BasePersistentFile):
563
572
  method: str,
564
573
  ) -> None:
565
574
  """Remove expired entries for a given (rpc_type, method) bucket without creating new ones."""
566
-
575
+ if self._ttl == 0:
576
+ return
567
577
  if not (bucket_by_method := self._store.get(rpc_type)):
568
578
  return
569
579
  if not (bucket_by_parameter := bucket_by_method.get(method)):
@@ -571,9 +581,7 @@ class SessionRecorder(BasePersistentFile):
571
581
  now = _now()
572
582
  empty_params: list[str] = []
573
583
  for p, bucket_by_ts in bucket_by_parameter.items():
574
- expired_ts = [
575
- ts for ts, (_r, ttl_s) in list(bucket_by_ts.items()) if _is_expired(ts=ts, ttl_s=ttl_s, now=now)
576
- ]
584
+ expired_ts = [ts for ts, _r in list(bucket_by_ts.items()) if self._is_expired(ts=ts, now=now)]
577
585
  for ts in expired_ts:
578
586
  del bucket_by_ts[ts]
579
587
  if not bucket_by_ts:
@@ -597,21 +605,24 @@ class SessionRecorder(BasePersistentFile):
597
605
  return self._active
598
606
 
599
607
  async def _deactivate_after_delay(
600
- self, *, delay: int, auto_save: bool, randomize_output: bool, use_ts_in_filename: bool
608
+ self, *, delay: int, auto_save: bool, randomize_output: bool, use_ts_in_file_name: bool
601
609
  ) -> None:
602
610
  """Change the state of the session recorder after a delay."""
603
- self._is_delayed = True
611
+ self._is_recording = True
604
612
  await asyncio.sleep(delay)
605
613
  self._active = False
606
- self._is_delayed = False
614
+ self._is_recording = False
607
615
  if auto_save:
608
- await self.save(randomize_output=randomize_output, use_ts_in_filename=use_ts_in_filename)
609
- _LOGGER.debug("Deactivated session recorder after %s minutes", {delay / 60})
616
+ await self.save(randomize_output=randomize_output, use_ts_in_file_name=use_ts_in_file_name)
617
+ _LOGGER.debug("Deactivated session recorder after %s seconds", {delay})
610
618
 
611
619
  async def activate(
612
- self, *, on_time: int = 0, auto_save: bool, randomize_output: bool, use_ts_in_filename: bool
613
- ) -> None:
620
+ self, *, on_time: int = 0, auto_save: bool, randomize_output: bool, use_ts_in_file_name: bool
621
+ ) -> bool:
614
622
  """Activate the session recorder. Disable after on_time(seconds)."""
623
+ if self._is_recording:
624
+ _LOGGER.info("ACTIVATE: Recording session is already running.")
625
+ return False
615
626
  self._store.clear()
616
627
  self._active = True
617
628
  if on_time > 0:
@@ -620,28 +631,33 @@ class SessionRecorder(BasePersistentFile):
620
631
  delay=on_time,
621
632
  auto_save=auto_save,
622
633
  randomize_output=randomize_output,
623
- use_ts_in_filename=use_ts_in_filename,
634
+ use_ts_in_file_name=use_ts_in_file_name,
624
635
  ),
625
636
  name=f"session_recorder_{self._central.name}",
626
637
  )
638
+ return True
627
639
 
628
640
  async def deactivate(
629
- self, *, delay: int, auto_save: bool, randomize_output: bool, use_ts_in_filename: bool
630
- ) -> None:
641
+ self, *, delay: int, auto_save: bool, randomize_output: bool, use_ts_in_file_name: bool
642
+ ) -> bool:
631
643
  """Deactivate the session recorder. Optionally after a delay(seconds)."""
644
+ if self._is_recording:
645
+ _LOGGER.info("DEACTIVATE: Recording session is already running.")
646
+ return False
632
647
  if delay > 0:
633
648
  self._central.looper.create_task(
634
649
  target=self._deactivate_after_delay(
635
650
  delay=delay,
636
651
  auto_save=auto_save,
637
652
  randomize_output=randomize_output,
638
- use_ts_in_filename=use_ts_in_filename,
653
+ use_ts_in_file_name=use_ts_in_file_name,
639
654
  ),
640
655
  name=f"session_recorder_{self._central.name}",
641
656
  )
642
657
  else:
643
658
  self._active = False
644
- self._is_delayed = False
659
+ self._is_recording = False
660
+ return True
645
661
 
646
662
  def add_json_rpc_session(
647
663
  self,
@@ -689,14 +705,11 @@ class SessionRecorder(BasePersistentFile):
689
705
  method: str,
690
706
  params: Any,
691
707
  response: Any,
692
- ttl_seconds: float | None = None,
693
708
  ts: int | datetime | None = None,
694
709
  ) -> Self:
695
710
  """Insert or update an entry."""
696
711
  self._purge_expired_at(rpc_type=rpc_type, method=method)
697
712
  frozen_param = _freeze_params(params)
698
- if (ttl_s := ttl_seconds if ttl_seconds is not None else self._default_ttl) <= 0:
699
- raise ValueError("ttl_seconds must be positive")
700
713
  # Normalize timestamp to int epoch seconds
701
714
  if isinstance(ts, datetime):
702
715
  ts_int = int(ts.timestamp())
@@ -704,7 +717,7 @@ class SessionRecorder(BasePersistentFile):
704
717
  ts_int = ts
705
718
  else:
706
719
  ts_int = _now()
707
- self._bucket(rpc_type=rpc_type, method=method)[frozen_param][ts_int] = (response, ttl_s)
720
+ self._bucket(rpc_type=rpc_type, method=method)[frozen_param][ts_int] = response
708
721
  return self
709
722
 
710
723
  def get(
@@ -736,9 +749,9 @@ class SessionRecorder(BasePersistentFile):
736
749
  latest_ts = max(bucket_by_ts.keys())
737
750
  except ValueError:
738
751
  return default
739
- resp, ttl_s = bucket_by_ts[latest_ts]
752
+ resp = bucket_by_ts[latest_ts]
740
753
  if self._refresh_on_get:
741
- bucket_by_ts[_now()] = (resp, ttl_s)
754
+ bucket_by_ts[_now()] = resp
742
755
  return resp
743
756
 
744
757
  def delete(self, *, rpc_type: str, method: str, params: Any) -> bool:
@@ -762,7 +775,7 @@ class SessionRecorder(BasePersistentFile):
762
775
  self._store.pop(rpc_type, None)
763
776
  return True
764
777
 
765
- def get_latest_fresh(self, *, rpc_type: str, method: str) -> list[tuple[Any, Any]]:
778
+ def get_latest_response_by_method(self, *, rpc_type: str, method: str) -> list[tuple[Any, Any]]:
766
779
  """Return latest non-expired responses for a given (rpc_type, method)."""
767
780
  # Purge expired entries first without creating any new buckets.
768
781
  self._purge_expired_at(rpc_type=rpc_type, method=method)
@@ -780,12 +793,40 @@ class SessionRecorder(BasePersistentFile):
780
793
  latest_ts = max(bucket_by_ts.keys())
781
794
  except ValueError:
782
795
  continue
783
- resp, _ttl_s = bucket_by_ts[latest_ts]
796
+ resp = bucket_by_ts[latest_ts]
784
797
  params = _unfreeze_params(frozen_params=frozen_params)
785
798
 
786
799
  result.append((params, resp))
787
800
  return result
788
801
 
802
+ def get_latest_response_by_params(
803
+ self,
804
+ *,
805
+ rpc_type: str,
806
+ method: str,
807
+ params: Any,
808
+ ) -> Any:
809
+ """Return latest non-expired responses for a given (rpc_type, method, params)."""
810
+ # Purge expired entries first without creating any new buckets.
811
+ self._purge_expired_at(rpc_type=rpc_type, method=method)
812
+
813
+ # Access store safely to avoid side effects from creating buckets.
814
+ if not (bucket_by_method := self._store.get(rpc_type)):
815
+ return None
816
+ if not (bucket_by_parameter := bucket_by_method.get(method)):
817
+ return None
818
+ frozen_params = _freeze_params(params=params)
819
+
820
+ # For each parameter, choose the response at the latest timestamp.
821
+ if (bucket_by_ts := bucket_by_parameter.get(frozen_params)) is None:
822
+ return None
823
+
824
+ try:
825
+ latest_ts = max(bucket_by_ts.keys())
826
+ return bucket_by_ts[latest_ts]
827
+ except ValueError:
828
+ return None
829
+
789
830
  def cleanup(self) -> None:
790
831
  """Purge all expired entries globally."""
791
832
  for rpc_type in list(self._store.keys()):
@@ -893,6 +934,8 @@ def _unfreeze_params(frozen_params: str) -> Any:
893
934
  return {k: _walk(v) for k, v in o.items()}
894
935
  if isinstance(o, list):
895
936
  return [_walk(x) for x in o]
937
+ if isinstance(o, tuple):
938
+ return tuple(_walk(x) for x in o)
896
939
  if o.startswith("{") and o.endswith("}"):
897
940
  return ast.literal_eval(o)
898
941
  return o
@@ -905,8 +948,8 @@ def _get_file_path(*, storage_directory: str, sub_directory: str) -> str:
905
948
  return f"{storage_directory}/{sub_directory}"
906
949
 
907
950
 
908
- def _get_filename(*, central_name: str, file_name: str, ts: datetime | None = None) -> str:
909
- """Return the content filename."""
951
+ def _get_file_name(*, central_name: str, file_name: str, ts: datetime | None = None) -> str:
952
+ """Return the content file_name."""
910
953
  fn = f"{slugify(central_name)}_{file_name}"
911
954
  if ts:
912
955
  fn += f"_{ts.strftime(FILE_NAME_TS_PATTERN)}"
@@ -918,12 +961,6 @@ def _now() -> int:
918
961
  return int(datetime.now(tz=UTC).timestamp())
919
962
 
920
963
 
921
- def _is_expired(*, ts: int, ttl_s: float, now: int | None = None) -> bool:
922
- """Check whether an entry has expired given epoch seconds."""
923
- now = now if now is not None else _now()
924
- return (now - ts) > ttl_s
925
-
926
-
927
964
  async def cleanup_files(*, central_name: str, storage_directory: str) -> None:
928
965
  """Clean up the used files."""
929
966
  loop = asyncio.get_running_loop()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiohomematic
3
- Version: 2025.10.9
3
+ Version: 2025.10.10
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>
@@ -103,6 +103,7 @@ tests/test_logging_support.py
103
103
  tests/test_number.py
104
104
  tests/test_select.py
105
105
  tests/test_sensor.py
106
+ tests/test_session_recorder.py
106
107
  tests/test_siren.py
107
108
  tests/test_support.py
108
109
  tests/test_support_extra.py
@@ -269,7 +269,7 @@ class ClientLocal(Client): # pragma: no cover
269
269
  data := await self._load_json_file(
270
270
  anchor=self._local_resources.anchor,
271
271
  resource=self._local_resources.paramset_description_dir,
272
- filename=file_name,
272
+ file_name=file_name,
273
273
  )
274
274
  )
275
275
  ):
@@ -329,20 +329,20 @@ class ClientLocal(Client): # pragma: no cover
329
329
  exclude_list = []
330
330
  result: list[Any] = []
331
331
  resource_path = os.path.join(str(importlib.resources.files(anchor)), resource)
332
- for filename in os.listdir(resource_path):
333
- if filename not in include_list or filename in exclude_list:
332
+ for file_name in os.listdir(resource_path):
333
+ if file_name not in include_list or file_name in exclude_list:
334
334
  continue
335
- if file_content := await self._load_json_file(anchor=anchor, resource=resource, filename=filename):
335
+ if file_content := await self._load_json_file(anchor=anchor, resource=resource, file_name=file_name):
336
336
  result.append(file_content)
337
337
  return result
338
338
 
339
- async def _load_json_file(self, *, anchor: str, resource: str, filename: str) -> Any | None:
339
+ async def _load_json_file(self, *, anchor: str, resource: str, file_name: str) -> Any | None:
340
340
  """Load json file from disk into dict."""
341
341
  package_path = str(importlib.resources.files(anchor))
342
342
 
343
343
  def _perform_load() -> Any | None:
344
344
  with open(
345
- file=os.path.join(package_path, resource, filename),
345
+ file=os.path.join(package_path, resource, file_name),
346
346
  encoding=UTF_8,
347
347
  ) as fptr:
348
348
  return orjson.loads(fptr.read())
@@ -599,7 +599,7 @@ async def test_add_device(
599
599
  assert len(central.get_data_points(exclude_no_create=False)) == 33
600
600
  assert len(central.device_descriptions._raw_device_descriptions.get(const.INTERFACE_ID)) == 9
601
601
  assert len(central.paramset_descriptions._raw_paramset_descriptions.get(const.INTERFACE_ID)) == 9
602
- dev_desc = helper.load_device_description(central=central, filename="HmIP-BSM.json")
602
+ dev_desc = helper.load_device_description(central=central, file_name="HmIP-BSM.json")
603
603
  await central.add_new_devices(interface_id=const.INTERFACE_ID, device_descriptions=dev_desc)
604
604
  assert len(central._devices) == 2
605
605
  assert len(central.get_data_points(exclude_no_create=False)) == 64
@@ -0,0 +1,38 @@
1
+ """Test the session recorder."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from aiohomematic.const import RPCType
8
+
9
+
10
+ @pytest.mark.enable_socket
11
+ @pytest.mark.asyncio
12
+ async def test_session_recorder(session_recorder_from_full_session) -> None:
13
+ """Test the session recorder."""
14
+ assert session_recorder_from_full_session
15
+ lm_methods = session_recorder_from_full_session.get_latest_response_by_method(
16
+ rpc_type=RPCType.XML_RPC, method="system.listMethods"
17
+ )
18
+ assert lm_methods
19
+ assert len(lm_methods) == 1
20
+
21
+ assert session_recorder_from_full_session
22
+ pd_methods = session_recorder_from_full_session.get_latest_response_by_method(
23
+ rpc_type=RPCType.XML_RPC, method="getParamsetDescription"
24
+ )
25
+ assert pd_methods
26
+ assert len(pd_methods) == 3561
27
+
28
+ list_methods = session_recorder_from_full_session.get_latest_response_by_params(
29
+ rpc_type=RPCType.XML_RPC, method="system.listMethods", params=()
30
+ )
31
+ assert list_methods
32
+ assert len(list_methods) == 53
33
+
34
+ dd_mestods = session_recorder_from_full_session.get_latest_response_by_params(
35
+ rpc_type=RPCType.JSON_RPC, method="Interface.listInterfaces", params="{'_session_id_': 'DzzhYRjWXr'}"
36
+ )
37
+ assert dd_mestods
38
+ assert len(dd_mestods["result"]) == 3