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.
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/PKG-INFO +1 -1
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/central/__init__.py +20 -9
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/client/__init__.py +4 -2
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/const.py +18 -9
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/custom/climate.py +4 -4
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/data_point.py +3 -4
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/device.py +5 -5
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/update.py +2 -2
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/store/persistent.py +98 -61
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic.egg-info/PKG-INFO +1 -1
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic.egg-info/SOURCES.txt +1 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic_support/client_local.py +6 -6
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_central.py +1 -1
- aiohomematic-2025.10.10/tests/test_session_recorder.py +38 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/LICENSE +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/README.md +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/__init__.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/async_support.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/central/decorators.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/central/rpc_server.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/client/_rpc_errors.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/client/json_rpc.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/client/rpc_proxy.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/context.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/converter.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/decorators.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/exceptions.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/hmcli.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/__init__.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/calculated/__init__.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/calculated/climate.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/calculated/data_point.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/calculated/operating_voltage_level.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/calculated/support.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/custom/__init__.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/custom/const.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/custom/cover.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/custom/data_point.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/custom/definition.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/custom/light.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/custom/lock.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/custom/siren.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/custom/support.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/custom/switch.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/custom/valve.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/event.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/generic/__init__.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/generic/action.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/generic/binary_sensor.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/generic/button.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/generic/data_point.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/generic/number.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/generic/select.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/generic/sensor.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/generic/switch.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/generic/text.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/hub/__init__.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/hub/binary_sensor.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/hub/button.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/hub/data_point.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/hub/number.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/hub/select.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/hub/sensor.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/hub/switch.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/hub/text.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/support.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/property_decorators.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/py.typed +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/rega_scripts/fetch_all_device_data.fn +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/rega_scripts/get_program_descriptions.fn +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/rega_scripts/get_serial.fn +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/rega_scripts/get_system_variable_descriptions.fn +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/rega_scripts/set_program_state.fn +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/rega_scripts/set_system_variable.fn +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/store/__init__.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/store/dynamic.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/store/visibility.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/support.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/validator.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic.egg-info/dependency_links.txt +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic.egg-info/requires.txt +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic.egg-info/top_level.txt +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic_support/__init__.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/pyproject.toml +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/setup.cfg +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_action.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_async_support.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_binary_sensor.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_button.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_calculated_support.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_central_pydevccu.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_climate.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_cover.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_decorator.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_device.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_dynamic_caches.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_entity.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_event.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_json_rpc.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_json_rpc_client_integration.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_kwonly_lint.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_light.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_lock.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_logging_support.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_number.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_select.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_sensor.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_siren.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_support.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_support_extra.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_switch.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_text.py +0 -0
- {aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_valve.py +0 -0
- {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.
|
|
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,
|
|
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.
|
|
499
|
+
if self._config.session_recorder_start:
|
|
497
500
|
await self._recorder.deactivate(
|
|
498
|
-
delay=self._config.
|
|
501
|
+
delay=self._config.session_recorder_start_for_seconds,
|
|
499
502
|
auto_save=True,
|
|
500
|
-
randomize_output=
|
|
501
|
-
|
|
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
|
|
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.
|
|
2052
|
-
|
|
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(
|
|
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.
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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 !=
|
|
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
|
|
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
|
-
"
|
|
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
|
|
126
|
+
def _get_file_name(
|
|
127
127
|
self,
|
|
128
128
|
*,
|
|
129
|
-
|
|
129
|
+
use_ts_in_file_name: bool = False,
|
|
130
130
|
) -> str:
|
|
131
131
|
"""Return the file name."""
|
|
132
|
-
return
|
|
132
|
+
return _get_file_name(
|
|
133
133
|
central_name=self._central.name,
|
|
134
134
|
file_name=self._file_postfix,
|
|
135
|
-
ts=datetime.now() if
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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(
|
|
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.
|
|
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)
|
|
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=
|
|
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.
|
|
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]
|
|
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
|
-
"
|
|
524
|
-
"
|
|
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
|
|
544
|
+
if ttl_seconds < 0:
|
|
543
545
|
raise ValueError("default_ttl_seconds must be positive")
|
|
544
|
-
self.
|
|
545
|
-
self.
|
|
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) ->
|
|
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,
|
|
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,
|
|
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.
|
|
611
|
+
self._is_recording = True
|
|
604
612
|
await asyncio.sleep(delay)
|
|
605
613
|
self._active = False
|
|
606
|
-
self.
|
|
614
|
+
self._is_recording = False
|
|
607
615
|
if auto_save:
|
|
608
|
-
await self.save(randomize_output=randomize_output,
|
|
609
|
-
_LOGGER.debug("Deactivated session recorder after %s
|
|
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,
|
|
613
|
-
) ->
|
|
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
|
-
|
|
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,
|
|
630
|
-
) ->
|
|
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
|
-
|
|
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.
|
|
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] =
|
|
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
|
|
752
|
+
resp = bucket_by_ts[latest_ts]
|
|
740
753
|
if self._refresh_on_get:
|
|
741
|
-
bucket_by_ts[_now()] =
|
|
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
|
|
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
|
|
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
|
|
909
|
-
"""Return the content
|
|
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.
|
|
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>
|
|
@@ -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
|
-
|
|
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
|
|
333
|
-
if
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/calculated/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/calculated/data_point.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/model/generic/binary_sensor.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/rega_scripts/set_program_state.fn
RENAMED
|
File without changes
|
{aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic/rega_scripts/set_system_variable.fn
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/aiohomematic.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aiohomematic-2025.10.9 → aiohomematic-2025.10.10}/tests/test_json_rpc_client_integration.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|