aiohomematic 2026.1.29__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aiohomematic/__init__.py +110 -0
- aiohomematic/_log_context_protocol.py +29 -0
- aiohomematic/api.py +410 -0
- aiohomematic/async_support.py +250 -0
- aiohomematic/backend_detection.py +462 -0
- aiohomematic/central/__init__.py +103 -0
- aiohomematic/central/async_rpc_server.py +760 -0
- aiohomematic/central/central_unit.py +1152 -0
- aiohomematic/central/config.py +463 -0
- aiohomematic/central/config_builder.py +772 -0
- aiohomematic/central/connection_state.py +160 -0
- aiohomematic/central/coordinators/__init__.py +38 -0
- aiohomematic/central/coordinators/cache.py +414 -0
- aiohomematic/central/coordinators/client.py +480 -0
- aiohomematic/central/coordinators/connection_recovery.py +1141 -0
- aiohomematic/central/coordinators/device.py +1166 -0
- aiohomematic/central/coordinators/event.py +514 -0
- aiohomematic/central/coordinators/hub.py +532 -0
- aiohomematic/central/decorators.py +184 -0
- aiohomematic/central/device_registry.py +229 -0
- aiohomematic/central/events/__init__.py +104 -0
- aiohomematic/central/events/bus.py +1392 -0
- aiohomematic/central/events/integration.py +424 -0
- aiohomematic/central/events/types.py +194 -0
- aiohomematic/central/health.py +762 -0
- aiohomematic/central/rpc_server.py +353 -0
- aiohomematic/central/scheduler.py +794 -0
- aiohomematic/central/state_machine.py +391 -0
- aiohomematic/client/__init__.py +203 -0
- aiohomematic/client/_rpc_errors.py +187 -0
- aiohomematic/client/backends/__init__.py +48 -0
- aiohomematic/client/backends/base.py +335 -0
- aiohomematic/client/backends/capabilities.py +138 -0
- aiohomematic/client/backends/ccu.py +487 -0
- aiohomematic/client/backends/factory.py +116 -0
- aiohomematic/client/backends/homegear.py +294 -0
- aiohomematic/client/backends/json_ccu.py +252 -0
- aiohomematic/client/backends/protocol.py +316 -0
- aiohomematic/client/ccu.py +1857 -0
- aiohomematic/client/circuit_breaker.py +459 -0
- aiohomematic/client/config.py +64 -0
- aiohomematic/client/handlers/__init__.py +40 -0
- aiohomematic/client/handlers/backup.py +157 -0
- aiohomematic/client/handlers/base.py +79 -0
- aiohomematic/client/handlers/device_ops.py +1085 -0
- aiohomematic/client/handlers/firmware.py +144 -0
- aiohomematic/client/handlers/link_mgmt.py +199 -0
- aiohomematic/client/handlers/metadata.py +436 -0
- aiohomematic/client/handlers/programs.py +144 -0
- aiohomematic/client/handlers/sysvars.py +100 -0
- aiohomematic/client/interface_client.py +1304 -0
- aiohomematic/client/json_rpc.py +2068 -0
- aiohomematic/client/request_coalescer.py +282 -0
- aiohomematic/client/rpc_proxy.py +629 -0
- aiohomematic/client/state_machine.py +324 -0
- aiohomematic/const.py +2207 -0
- aiohomematic/context.py +275 -0
- aiohomematic/converter.py +270 -0
- aiohomematic/decorators.py +390 -0
- aiohomematic/exceptions.py +185 -0
- aiohomematic/hmcli.py +997 -0
- aiohomematic/i18n.py +193 -0
- aiohomematic/interfaces/__init__.py +407 -0
- aiohomematic/interfaces/central.py +1067 -0
- aiohomematic/interfaces/client.py +1096 -0
- aiohomematic/interfaces/coordinators.py +63 -0
- aiohomematic/interfaces/model.py +1921 -0
- aiohomematic/interfaces/operations.py +217 -0
- aiohomematic/logging_context.py +134 -0
- aiohomematic/metrics/__init__.py +125 -0
- aiohomematic/metrics/_protocols.py +140 -0
- aiohomematic/metrics/aggregator.py +534 -0
- aiohomematic/metrics/dataclasses.py +489 -0
- aiohomematic/metrics/emitter.py +292 -0
- aiohomematic/metrics/events.py +183 -0
- aiohomematic/metrics/keys.py +300 -0
- aiohomematic/metrics/observer.py +563 -0
- aiohomematic/metrics/stats.py +172 -0
- aiohomematic/model/__init__.py +189 -0
- aiohomematic/model/availability.py +65 -0
- aiohomematic/model/calculated/__init__.py +89 -0
- aiohomematic/model/calculated/climate.py +276 -0
- aiohomematic/model/calculated/data_point.py +315 -0
- aiohomematic/model/calculated/field.py +147 -0
- aiohomematic/model/calculated/operating_voltage_level.py +286 -0
- aiohomematic/model/calculated/support.py +232 -0
- aiohomematic/model/custom/__init__.py +214 -0
- aiohomematic/model/custom/capabilities/__init__.py +67 -0
- aiohomematic/model/custom/capabilities/climate.py +41 -0
- aiohomematic/model/custom/capabilities/light.py +87 -0
- aiohomematic/model/custom/capabilities/lock.py +44 -0
- aiohomematic/model/custom/capabilities/siren.py +63 -0
- aiohomematic/model/custom/climate.py +1130 -0
- aiohomematic/model/custom/cover.py +722 -0
- aiohomematic/model/custom/data_point.py +360 -0
- aiohomematic/model/custom/definition.py +300 -0
- aiohomematic/model/custom/field.py +89 -0
- aiohomematic/model/custom/light.py +1174 -0
- aiohomematic/model/custom/lock.py +322 -0
- aiohomematic/model/custom/mixins.py +445 -0
- aiohomematic/model/custom/profile.py +945 -0
- aiohomematic/model/custom/registry.py +251 -0
- aiohomematic/model/custom/siren.py +462 -0
- aiohomematic/model/custom/switch.py +195 -0
- aiohomematic/model/custom/text_display.py +289 -0
- aiohomematic/model/custom/valve.py +78 -0
- aiohomematic/model/data_point.py +1416 -0
- aiohomematic/model/device.py +1840 -0
- aiohomematic/model/event.py +216 -0
- aiohomematic/model/generic/__init__.py +327 -0
- aiohomematic/model/generic/action.py +40 -0
- aiohomematic/model/generic/action_select.py +62 -0
- aiohomematic/model/generic/binary_sensor.py +30 -0
- aiohomematic/model/generic/button.py +31 -0
- aiohomematic/model/generic/data_point.py +177 -0
- aiohomematic/model/generic/dummy.py +150 -0
- aiohomematic/model/generic/number.py +76 -0
- aiohomematic/model/generic/select.py +56 -0
- aiohomematic/model/generic/sensor.py +76 -0
- aiohomematic/model/generic/switch.py +54 -0
- aiohomematic/model/generic/text.py +33 -0
- aiohomematic/model/hub/__init__.py +100 -0
- aiohomematic/model/hub/binary_sensor.py +24 -0
- aiohomematic/model/hub/button.py +28 -0
- aiohomematic/model/hub/connectivity.py +190 -0
- aiohomematic/model/hub/data_point.py +342 -0
- aiohomematic/model/hub/hub.py +864 -0
- aiohomematic/model/hub/inbox.py +135 -0
- aiohomematic/model/hub/install_mode.py +393 -0
- aiohomematic/model/hub/metrics.py +208 -0
- aiohomematic/model/hub/number.py +42 -0
- aiohomematic/model/hub/select.py +52 -0
- aiohomematic/model/hub/sensor.py +37 -0
- aiohomematic/model/hub/switch.py +43 -0
- aiohomematic/model/hub/text.py +30 -0
- aiohomematic/model/hub/update.py +221 -0
- aiohomematic/model/support.py +592 -0
- aiohomematic/model/update.py +140 -0
- aiohomematic/model/week_profile.py +1827 -0
- aiohomematic/property_decorators.py +719 -0
- aiohomematic/py.typed +0 -0
- aiohomematic/rega_scripts/accept_device_in_inbox.fn +51 -0
- aiohomematic/rega_scripts/create_backup_start.fn +28 -0
- aiohomematic/rega_scripts/create_backup_status.fn +89 -0
- aiohomematic/rega_scripts/fetch_all_device_data.fn +97 -0
- aiohomematic/rega_scripts/get_backend_info.fn +25 -0
- aiohomematic/rega_scripts/get_inbox_devices.fn +61 -0
- aiohomematic/rega_scripts/get_program_descriptions.fn +31 -0
- aiohomematic/rega_scripts/get_serial.fn +44 -0
- aiohomematic/rega_scripts/get_service_messages.fn +83 -0
- aiohomematic/rega_scripts/get_system_update_info.fn +39 -0
- aiohomematic/rega_scripts/get_system_variable_descriptions.fn +31 -0
- aiohomematic/rega_scripts/set_program_state.fn +17 -0
- aiohomematic/rega_scripts/set_system_variable.fn +19 -0
- aiohomematic/rega_scripts/trigger_firmware_update.fn +67 -0
- aiohomematic/schemas.py +256 -0
- aiohomematic/store/__init__.py +55 -0
- aiohomematic/store/dynamic/__init__.py +43 -0
- aiohomematic/store/dynamic/command.py +250 -0
- aiohomematic/store/dynamic/data.py +175 -0
- aiohomematic/store/dynamic/details.py +187 -0
- aiohomematic/store/dynamic/ping_pong.py +416 -0
- aiohomematic/store/persistent/__init__.py +71 -0
- aiohomematic/store/persistent/base.py +285 -0
- aiohomematic/store/persistent/device.py +233 -0
- aiohomematic/store/persistent/incident.py +380 -0
- aiohomematic/store/persistent/paramset.py +241 -0
- aiohomematic/store/persistent/session.py +556 -0
- aiohomematic/store/serialization.py +150 -0
- aiohomematic/store/storage.py +689 -0
- aiohomematic/store/types.py +526 -0
- aiohomematic/store/visibility/__init__.py +40 -0
- aiohomematic/store/visibility/parser.py +141 -0
- aiohomematic/store/visibility/registry.py +722 -0
- aiohomematic/store/visibility/rules.py +307 -0
- aiohomematic/strings.json +237 -0
- aiohomematic/support.py +706 -0
- aiohomematic/tracing.py +236 -0
- aiohomematic/translations/de.json +237 -0
- aiohomematic/translations/en.json +237 -0
- aiohomematic/type_aliases.py +51 -0
- aiohomematic/validator.py +128 -0
- aiohomematic-2026.1.29.dist-info/METADATA +296 -0
- aiohomematic-2026.1.29.dist-info/RECORD +188 -0
- aiohomematic-2026.1.29.dist-info/WHEEL +5 -0
- aiohomematic-2026.1.29.dist-info/entry_points.txt +2 -0
- aiohomematic-2026.1.29.dist-info/licenses/LICENSE +21 -0
- aiohomematic-2026.1.29.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
!# name: trigger_firmware_update.fn
|
|
2
|
+
!#
|
|
3
|
+
!# This script triggers an unattended firmware update.
|
|
4
|
+
!# Only supported on OpenCCU (uses checkFirmwareUpdate.sh).
|
|
5
|
+
!#
|
|
6
|
+
!# The script validates:
|
|
7
|
+
!# 1. checkFirmwareUpdate.sh exists and is executable
|
|
8
|
+
!# 2. Script supports required flags (-a, -r) via -h output
|
|
9
|
+
!#
|
|
10
|
+
!# Then runs the update with nohup in background:
|
|
11
|
+
!# -a = apply update (download and stage)
|
|
12
|
+
!# -r = reboot immediately after staging
|
|
13
|
+
!#
|
|
14
|
+
!# Note: Backup (-b) is NOT used here. Use create_backup_and_download()
|
|
15
|
+
!# before triggering the firmware update to download a backup.
|
|
16
|
+
!#
|
|
17
|
+
!# The script is started with nohup to ensure it continues running
|
|
18
|
+
!# even if the connection is lost during the update/reboot process.
|
|
19
|
+
!#
|
|
20
|
+
|
|
21
|
+
string sResult = "";
|
|
22
|
+
boolean bScriptAvailable = false;
|
|
23
|
+
boolean bSuccess = false;
|
|
24
|
+
string sMessage = "";
|
|
25
|
+
|
|
26
|
+
!# Check if checkFirmwareUpdate.sh is available and supports required flags
|
|
27
|
+
system.Exec("test -x /bin/checkFirmwareUpdate.sh && /bin/checkFirmwareUpdate.sh -h 2>&1", &sResult);
|
|
28
|
+
if (sResult) {
|
|
29
|
+
!# Verify script supports required flags: -a (apply), -r (reboot)
|
|
30
|
+
boolean bHasApply = (sResult.Find("-a") >= 0);
|
|
31
|
+
boolean bHasReboot = (sResult.Find("-r") >= 0);
|
|
32
|
+
|
|
33
|
+
if (bHasApply && bHasReboot) {
|
|
34
|
+
bScriptAvailable = true;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (bScriptAvailable) {
|
|
39
|
+
!# Save system state before update
|
|
40
|
+
system.Save();
|
|
41
|
+
|
|
42
|
+
!# Run checkFirmwareUpdate.sh with nohup in background
|
|
43
|
+
!# -a = apply update (download and stage)
|
|
44
|
+
!# -r = reboot immediately after staging
|
|
45
|
+
!# Note: Use create_backup_and_download() before this for backup
|
|
46
|
+
sResult = "";
|
|
47
|
+
system.Exec("nohup /bin/checkFirmwareUpdate.sh -a -r >/dev/null 2>&1 &", &sResult);
|
|
48
|
+
|
|
49
|
+
bSuccess = true;
|
|
50
|
+
sMessage = "Firmware update triggered, system will reboot when ready";
|
|
51
|
+
} else {
|
|
52
|
+
sMessage = "checkFirmwareUpdate.sh not available or missing required flags (only supported on OpenCCU)";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
Write('{"success":');
|
|
56
|
+
if (bSuccess) {
|
|
57
|
+
Write('true,');
|
|
58
|
+
} else {
|
|
59
|
+
Write('false,');
|
|
60
|
+
}
|
|
61
|
+
Write('"script_available":');
|
|
62
|
+
if (bScriptAvailable) {
|
|
63
|
+
Write('true,');
|
|
64
|
+
} else {
|
|
65
|
+
Write('false,');
|
|
66
|
+
}
|
|
67
|
+
Write('"message":"' # sMessage # '"}');
|
aiohomematic/schemas.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Validation and normalization schemas for API data structures.
|
|
3
|
+
|
|
4
|
+
Uses voluptuous to validate and normalize data received from Homematic backends.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Final
|
|
11
|
+
|
|
12
|
+
import voluptuous as vol
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from aiohomematic.const import DeviceDescription, ParameterData
|
|
16
|
+
|
|
17
|
+
_LOGGER: Final = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
# ============================================================================
|
|
20
|
+
# DeviceDescription Schema
|
|
21
|
+
# ============================================================================
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _normalize_children(*, value: Any) -> list[str]:
|
|
25
|
+
"""Normalize CHILDREN field to always be a list."""
|
|
26
|
+
if value is None:
|
|
27
|
+
return []
|
|
28
|
+
if isinstance(value, str):
|
|
29
|
+
return [] if value == "" else [value]
|
|
30
|
+
if isinstance(value, (list, tuple)):
|
|
31
|
+
return list(value)
|
|
32
|
+
return []
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _normalize_paramsets(*, value: Any) -> list[str]:
|
|
36
|
+
"""Normalize PARAMSETS field to always be a list."""
|
|
37
|
+
if value is None:
|
|
38
|
+
return ["MASTER", "VALUES"]
|
|
39
|
+
if isinstance(value, (list, tuple)):
|
|
40
|
+
return list(value)
|
|
41
|
+
return ["MASTER", "VALUES"]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
DEVICE_DESCRIPTION_SCHEMA = vol.Schema(
|
|
45
|
+
{
|
|
46
|
+
# Required fields per API spec
|
|
47
|
+
vol.Required("TYPE"): vol.Coerce(str),
|
|
48
|
+
vol.Required("ADDRESS"): vol.Coerce(str),
|
|
49
|
+
vol.Required("PARAMSETS", default=["MASTER", "VALUES"]): lambda x: _normalize_paramsets(value=x),
|
|
50
|
+
# Optional fields with normalization
|
|
51
|
+
vol.Optional("CHILDREN", default=[]): lambda x: _normalize_children(value=x),
|
|
52
|
+
vol.Optional("PARENT"): vol.Any(None, str),
|
|
53
|
+
vol.Optional("PARENT_TYPE"): vol.Any(None, str),
|
|
54
|
+
vol.Optional("SUBTYPE"): vol.Any(None, str),
|
|
55
|
+
vol.Optional("FIRMWARE"): vol.Any(None, str),
|
|
56
|
+
vol.Optional("AVAILABLE_FIRMWARE"): vol.Any(None, str),
|
|
57
|
+
vol.Optional("UPDATABLE"): vol.Coerce(bool),
|
|
58
|
+
vol.Optional("FIRMWARE_UPDATE_STATE"): vol.Any(None, str),
|
|
59
|
+
vol.Optional("FIRMWARE_UPDATABLE"): vol.Any(None, bool),
|
|
60
|
+
vol.Optional("INTERFACE"): vol.Any(None, str),
|
|
61
|
+
# Per API spec: RX_MODE is Integer (bitmask)
|
|
62
|
+
vol.Optional("RX_MODE"): vol.Any(None, vol.Coerce(int)),
|
|
63
|
+
vol.Optional("LINK_SOURCE_ROLES"): vol.Any(None, str),
|
|
64
|
+
vol.Optional("LINK_TARGET_ROLES"): vol.Any(None, str),
|
|
65
|
+
# Additional fields from spec (currently commented in const.py)
|
|
66
|
+
vol.Optional("RF_ADDRESS"): vol.Any(None, vol.Coerce(int)),
|
|
67
|
+
vol.Optional("INDEX"): vol.Any(None, vol.Coerce(int)),
|
|
68
|
+
vol.Optional("AES_ACTIVE"): vol.Any(None, vol.Coerce(int)),
|
|
69
|
+
vol.Optional("VERSION"): vol.Any(None, vol.Coerce(int)),
|
|
70
|
+
vol.Optional("FLAGS"): vol.Any(None, vol.Coerce(int)),
|
|
71
|
+
vol.Optional("DIRECTION"): vol.Any(None, vol.Coerce(int)),
|
|
72
|
+
vol.Optional("GROUP"): vol.Any(None, str),
|
|
73
|
+
vol.Optional("TEAM"): vol.Any(None, str),
|
|
74
|
+
vol.Optional("TEAM_TAG"): vol.Any(None, str),
|
|
75
|
+
vol.Optional("TEAM_CHANNELS"): vol.Any(None, list),
|
|
76
|
+
vol.Optional("ROAMING"): vol.Any(None, vol.Coerce(int)),
|
|
77
|
+
},
|
|
78
|
+
extra=vol.ALLOW_EXTRA, # Allow backend-specific extra fields
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ============================================================================
|
|
83
|
+
# ParameterData Schema (ParameterDescription in API)
|
|
84
|
+
# ============================================================================
|
|
85
|
+
|
|
86
|
+
# Parameter TYPE values per API spec
|
|
87
|
+
VALID_PARAMETER_TYPES = {
|
|
88
|
+
"FLOAT",
|
|
89
|
+
"INTEGER",
|
|
90
|
+
"BOOL",
|
|
91
|
+
"ENUM",
|
|
92
|
+
"STRING",
|
|
93
|
+
"ACTION",
|
|
94
|
+
# Additional types found in practice
|
|
95
|
+
"DUMMY",
|
|
96
|
+
"",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _normalize_parameter_type(*, value: Any) -> str:
|
|
101
|
+
"""Normalize and validate parameter TYPE field."""
|
|
102
|
+
if value is None:
|
|
103
|
+
return ""
|
|
104
|
+
str_val = str(value).upper()
|
|
105
|
+
return str_val if str_val in VALID_PARAMETER_TYPES else ""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _normalize_operations(*, value: Any) -> int:
|
|
109
|
+
"""Normalize OPERATIONS to integer bitmask."""
|
|
110
|
+
if value is None:
|
|
111
|
+
return 0
|
|
112
|
+
try:
|
|
113
|
+
return int(value)
|
|
114
|
+
except (ValueError, TypeError):
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _normalize_flags(*, value: Any) -> int:
|
|
119
|
+
"""Normalize FLAGS to integer bitmask."""
|
|
120
|
+
if value is None:
|
|
121
|
+
return 0
|
|
122
|
+
try:
|
|
123
|
+
return int(value)
|
|
124
|
+
except (ValueError, TypeError):
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _normalize_value_list(*, value: Any) -> list[str]:
|
|
129
|
+
"""Normalize VALUE_LIST to list of strings."""
|
|
130
|
+
if value is None:
|
|
131
|
+
return []
|
|
132
|
+
if not isinstance(value, (list, tuple)):
|
|
133
|
+
return []
|
|
134
|
+
# Convert all items to strings
|
|
135
|
+
return [str(item) for item in value]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
PARAMETER_DATA_SCHEMA = vol.Schema(
|
|
139
|
+
{
|
|
140
|
+
vol.Optional("TYPE"): lambda x: _normalize_parameter_type(value=x),
|
|
141
|
+
vol.Optional("OPERATIONS", default=0): lambda x: _normalize_operations(value=x),
|
|
142
|
+
vol.Optional("FLAGS", default=0): lambda x: _normalize_flags(value=x),
|
|
143
|
+
vol.Optional("DEFAULT"): vol.Any(None, str, int, float, bool),
|
|
144
|
+
vol.Optional("MAX"): vol.Any(None, str, int, float),
|
|
145
|
+
vol.Optional("MIN"): vol.Any(None, str, int, float),
|
|
146
|
+
vol.Optional("UNIT"): vol.Any(None, str),
|
|
147
|
+
vol.Optional("ID"): vol.Any(None, str),
|
|
148
|
+
# Per API spec: TAB_ORDER is Integer (display ordering)
|
|
149
|
+
vol.Optional("TAB_ORDER"): vol.Any(None, vol.Coerce(int)),
|
|
150
|
+
# Per API spec: CONTROL is String (UI hint)
|
|
151
|
+
vol.Optional("CONTROL"): vol.Any(None, str),
|
|
152
|
+
# Per API spec: VALUE_LIST is Array of String (for ENUM type)
|
|
153
|
+
vol.Optional("VALUE_LIST", default=[]): lambda x: _normalize_value_list(value=x),
|
|
154
|
+
# Per API spec: SPECIAL is Array of Struct {ID: String, VALUE: <TYPE>}
|
|
155
|
+
# In practice: Dict with special value IDs as keys (e.g., {"NOT_USED": 111600.0})
|
|
156
|
+
vol.Optional("SPECIAL"): vol.Any(None, list, dict),
|
|
157
|
+
},
|
|
158
|
+
extra=vol.ALLOW_EXTRA,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ============================================================================
|
|
163
|
+
# ParamsetDescription Schema
|
|
164
|
+
# ============================================================================
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def normalize_paramset_description(
|
|
168
|
+
*,
|
|
169
|
+
paramset: dict[str, Any] | None,
|
|
170
|
+
) -> dict[str, ParameterData]:
|
|
171
|
+
"""
|
|
172
|
+
Normalize a paramset description dict.
|
|
173
|
+
|
|
174
|
+
A ParamsetDescription is a Struct where each key is a parameter name
|
|
175
|
+
and each value is a ParameterDescription (ParameterData).
|
|
176
|
+
"""
|
|
177
|
+
if paramset is None:
|
|
178
|
+
return {}
|
|
179
|
+
result: dict[str, ParameterData] = {}
|
|
180
|
+
for param_name, param_data in paramset.items():
|
|
181
|
+
try:
|
|
182
|
+
result[param_name] = PARAMETER_DATA_SCHEMA(param_data)
|
|
183
|
+
except vol.Invalid as err:
|
|
184
|
+
# Log validation failures for debugging
|
|
185
|
+
_LOGGER.debug(
|
|
186
|
+
"Parameter validation failed for %s: %s. Using raw data.",
|
|
187
|
+
param_name,
|
|
188
|
+
err,
|
|
189
|
+
)
|
|
190
|
+
# Keep original data if validation fails
|
|
191
|
+
result[param_name] = param_data
|
|
192
|
+
return result
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ============================================================================
|
|
196
|
+
# Public API
|
|
197
|
+
# ============================================================================
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def normalize_device_description(*, device_description: dict[str, Any] | DeviceDescription) -> DeviceDescription:
|
|
201
|
+
"""
|
|
202
|
+
Normalize a device description dict.
|
|
203
|
+
|
|
204
|
+
Should be called at all ingestion points:
|
|
205
|
+
- After receiving from list_devices()
|
|
206
|
+
- After receiving from get_device_description()
|
|
207
|
+
- After receiving from newDevices() callback
|
|
208
|
+
- After loading from cache
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
device_description: Raw device description from backend or cache.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
Normalized DeviceDescription dict with guaranteed field types.
|
|
215
|
+
|
|
216
|
+
"""
|
|
217
|
+
try:
|
|
218
|
+
return dict(DEVICE_DESCRIPTION_SCHEMA(device_description)) # type: ignore[return-value]
|
|
219
|
+
except vol.Invalid as err:
|
|
220
|
+
# Log validation failures for debugging
|
|
221
|
+
address = device_description.get("ADDRESS", "UNKNOWN")
|
|
222
|
+
_LOGGER.debug(
|
|
223
|
+
"Device description validation failed for %s: %s. Applying fallback normalization.",
|
|
224
|
+
address,
|
|
225
|
+
err,
|
|
226
|
+
)
|
|
227
|
+
# On validation failure, at minimum ensure CHILDREN is a list
|
|
228
|
+
result = dict(device_description)
|
|
229
|
+
children = result.get("CHILDREN")
|
|
230
|
+
if children is None or isinstance(children, str):
|
|
231
|
+
result["CHILDREN"] = []
|
|
232
|
+
return result # type: ignore[return-value]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def normalize_parameter_data(*, parameter_data: dict[str, Any]) -> ParameterData:
|
|
236
|
+
"""
|
|
237
|
+
Normalize a parameter data dict (ParameterDescription).
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
parameter_data: Raw parameter data from backend or cache.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
Normalized ParameterData dict with guaranteed field types.
|
|
244
|
+
|
|
245
|
+
"""
|
|
246
|
+
try:
|
|
247
|
+
return dict(PARAMETER_DATA_SCHEMA(parameter_data)) # type: ignore[return-value]
|
|
248
|
+
except vol.Invalid as err:
|
|
249
|
+
# Log validation failures for debugging
|
|
250
|
+
param_id = parameter_data.get("ID", "UNKNOWN")
|
|
251
|
+
_LOGGER.debug(
|
|
252
|
+
"Parameter data validation failed for %s: %s. Using raw data.",
|
|
253
|
+
param_id,
|
|
254
|
+
err,
|
|
255
|
+
)
|
|
256
|
+
return dict(parameter_data) # type: ignore[return-value]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2021-2026
|
|
3
|
+
"""
|
|
4
|
+
Store packages for AioHomematic.
|
|
5
|
+
|
|
6
|
+
This package groups store implementations used throughout the library:
|
|
7
|
+
- persistent: Long-lived on-disk registries for device and paramset descriptions.
|
|
8
|
+
- dynamic: Short-lived in-memory caches/trackers for runtime values and connection health.
|
|
9
|
+
- visibility: Parameter visibility rules to decide which parameters are relevant.
|
|
10
|
+
- storage: Abstraction layer for file persistence with factory pattern.
|
|
11
|
+
|
|
12
|
+
Package structure
|
|
13
|
+
-----------------
|
|
14
|
+
- storage.py: Storage abstraction with factory pattern for HA Store integration
|
|
15
|
+
- persistent/: DeviceDescriptionRegistry, ParamsetDescriptionRegistry, SessionRecorder
|
|
16
|
+
- dynamic/: CommandCache, DeviceDetailsCache, CentralDataCache, PingPongTracker
|
|
17
|
+
- visibility/: ParameterVisibilityRegistry
|
|
18
|
+
- types.py: Shared type definitions (CachedCommand, PongTracker, type aliases)
|
|
19
|
+
- serialization.py: Freeze/unfreeze utilities for session recording
|
|
20
|
+
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from aiohomematic.store.serialization import cleanup_params_for_session, freeze_params, unfreeze_params
|
|
26
|
+
from aiohomematic.store.storage import (
|
|
27
|
+
LocalStorageFactory,
|
|
28
|
+
MigrateFunc,
|
|
29
|
+
Storage,
|
|
30
|
+
StorageError,
|
|
31
|
+
StorageFactoryProtocol,
|
|
32
|
+
StorageProtocol,
|
|
33
|
+
)
|
|
34
|
+
from aiohomematic.store.types import CacheName, CacheStatistics, IncidentSeverity, IncidentSnapshot, IncidentType
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
# Cache
|
|
38
|
+
"CacheName",
|
|
39
|
+
"CacheStatistics",
|
|
40
|
+
# Incident types
|
|
41
|
+
"IncidentSeverity",
|
|
42
|
+
"IncidentSnapshot",
|
|
43
|
+
"IncidentType",
|
|
44
|
+
# Serialization
|
|
45
|
+
"cleanup_params_for_session",
|
|
46
|
+
"freeze_params",
|
|
47
|
+
"unfreeze_params",
|
|
48
|
+
# Storage abstraction
|
|
49
|
+
"LocalStorageFactory",
|
|
50
|
+
"MigrateFunc",
|
|
51
|
+
"Storage",
|
|
52
|
+
"StorageError",
|
|
53
|
+
"StorageFactoryProtocol",
|
|
54
|
+
"StorageProtocol",
|
|
55
|
+
]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2021-2026
|
|
3
|
+
"""
|
|
4
|
+
Dynamic store used at runtime by the central unit and clients.
|
|
5
|
+
|
|
6
|
+
This package provides short-lived, in-memory stores that support robust and efficient
|
|
7
|
+
communication with Homematic interfaces.
|
|
8
|
+
|
|
9
|
+
Package structure
|
|
10
|
+
-----------------
|
|
11
|
+
- command: CommandTracker for tracking sent commands
|
|
12
|
+
- details: DeviceDetailsCache for device metadata
|
|
13
|
+
- data: CentralDataCache for parameter values
|
|
14
|
+
- ping_pong: PingPongTracker for connection health monitoring
|
|
15
|
+
|
|
16
|
+
Key behaviors
|
|
17
|
+
-------------
|
|
18
|
+
- Stores are intentionally ephemeral and cleared/aged according to rules
|
|
19
|
+
- Memory footprint is kept predictable while improving responsiveness
|
|
20
|
+
|
|
21
|
+
Public API
|
|
22
|
+
----------
|
|
23
|
+
- CommandTracker: Tracks recently sent commands per data point
|
|
24
|
+
- DeviceDetailsCache: Device names, rooms, functions, interfaces
|
|
25
|
+
- CentralDataCache: Stores recently fetched parameter values
|
|
26
|
+
- PingPongTracker: Connection health monitoring via ping/pong
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from aiohomematic.store.dynamic.command import CommandTracker
|
|
32
|
+
from aiohomematic.store.dynamic.data import CentralDataCache
|
|
33
|
+
from aiohomematic.store.dynamic.details import DeviceDetailsCache
|
|
34
|
+
from aiohomematic.store.dynamic.ping_pong import PingPongTracker
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
# Caches
|
|
38
|
+
"CentralDataCache",
|
|
39
|
+
"DeviceDetailsCache",
|
|
40
|
+
# Trackers
|
|
41
|
+
"CommandTracker",
|
|
42
|
+
"PingPongTracker",
|
|
43
|
+
]
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2021-2026
|
|
3
|
+
"""
|
|
4
|
+
Command tracker for tracking recently sent commands.
|
|
5
|
+
|
|
6
|
+
This module provides CommandTracker which tracks recently sent commands per data point
|
|
7
|
+
with automatic expiry and configurable size limits to prevent unbounded memory growth.
|
|
8
|
+
|
|
9
|
+
Memory management strategy (three-tier approach):
|
|
10
|
+
1. Lazy cleanup: When tracker exceeds CLEANUP_THRESHOLD, remove expired entries
|
|
11
|
+
2. Warning threshold: Log warning when approaching MAX_SIZE (hysteresis prevents spam)
|
|
12
|
+
3. Hard limit eviction: When MAX_SIZE reached, remove oldest 20% of entries
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
import logging
|
|
19
|
+
from typing import Any, Final
|
|
20
|
+
|
|
21
|
+
from aiohomematic.const import (
|
|
22
|
+
COMMAND_TRACKER_MAX_SIZE,
|
|
23
|
+
COMMAND_TRACKER_WARNING_THRESHOLD,
|
|
24
|
+
DP_KEY_VALUE,
|
|
25
|
+
LAST_COMMAND_SEND_STORE_TIMEOUT,
|
|
26
|
+
LAST_COMMAND_SEND_TRACKER_CLEANUP_THRESHOLD,
|
|
27
|
+
DataPointKey,
|
|
28
|
+
ParamsetKey,
|
|
29
|
+
)
|
|
30
|
+
from aiohomematic.converter import CONVERTABLE_PARAMETERS, convert_combined_parameter_to_paramset
|
|
31
|
+
from aiohomematic.store.types import CachedCommand, TrackerStatistics
|
|
32
|
+
from aiohomematic.support import changed_within_seconds
|
|
33
|
+
|
|
34
|
+
_LOGGER: Final = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CommandTracker:
|
|
38
|
+
"""
|
|
39
|
+
Tracker for sent commands with resource limits.
|
|
40
|
+
|
|
41
|
+
Tracks recently sent commands per data point with automatic expiry
|
|
42
|
+
and configurable size limits to prevent unbounded memory growth.
|
|
43
|
+
|
|
44
|
+
Memory management strategy (three-tier approach):
|
|
45
|
+
1. Lazy cleanup: When tracker exceeds CLEANUP_THRESHOLD, remove expired entries
|
|
46
|
+
2. Warning threshold: Log warning when approaching MAX_SIZE (hysteresis prevents spam)
|
|
47
|
+
3. Hard limit eviction: When MAX_SIZE reached, remove oldest 20% of entries
|
|
48
|
+
|
|
49
|
+
The 20% eviction rate balances memory reclamation against the cost of repeated
|
|
50
|
+
evictions (avoiding evicting just 1 entry repeatedly).
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
__slots__ = (
|
|
54
|
+
"_interface_id",
|
|
55
|
+
"_last_send_command",
|
|
56
|
+
"_stats",
|
|
57
|
+
"_warning_logged",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def __init__(self, *, interface_id: str) -> None:
|
|
61
|
+
"""Initialize command tracker."""
|
|
62
|
+
self._interface_id: Final = interface_id
|
|
63
|
+
self._stats: Final = TrackerStatistics()
|
|
64
|
+
# Maps DataPointKey to CachedCommand for tracking recent commands.
|
|
65
|
+
# Used to detect duplicate sends and for unconfirmed value tracking.
|
|
66
|
+
self._last_send_command: Final[dict[DataPointKey, CachedCommand]] = {}
|
|
67
|
+
# Hysteresis flag to prevent repeated warning logs
|
|
68
|
+
self._warning_logged: bool = False
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def size(self) -> int:
|
|
72
|
+
"""Return the current tracker size."""
|
|
73
|
+
return len(self._last_send_command)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def statistics(self) -> TrackerStatistics:
|
|
77
|
+
"""Return the tracker statistics."""
|
|
78
|
+
return self._stats
|
|
79
|
+
|
|
80
|
+
def add_combined_parameter(
|
|
81
|
+
self, *, parameter: str, channel_address: str, combined_parameter: str
|
|
82
|
+
) -> set[DP_KEY_VALUE]:
|
|
83
|
+
"""Add data from combined parameter."""
|
|
84
|
+
if values := convert_combined_parameter_to_paramset(parameter=parameter, value=combined_parameter):
|
|
85
|
+
return self.add_put_paramset(
|
|
86
|
+
channel_address=channel_address,
|
|
87
|
+
paramset_key=ParamsetKey.VALUES,
|
|
88
|
+
values=values,
|
|
89
|
+
)
|
|
90
|
+
return set()
|
|
91
|
+
|
|
92
|
+
def add_put_paramset(
|
|
93
|
+
self, *, channel_address: str, paramset_key: ParamsetKey, values: dict[str, Any]
|
|
94
|
+
) -> set[DP_KEY_VALUE]:
|
|
95
|
+
"""Add data from put paramset command."""
|
|
96
|
+
# Cleanup expired entries when tracker size exceeds threshold
|
|
97
|
+
if len(self._last_send_command) > LAST_COMMAND_SEND_TRACKER_CLEANUP_THRESHOLD:
|
|
98
|
+
self.cleanup_expired()
|
|
99
|
+
|
|
100
|
+
# Enforce hard size limit
|
|
101
|
+
self._enforce_size_limit()
|
|
102
|
+
|
|
103
|
+
dpk_values: set[DP_KEY_VALUE] = set()
|
|
104
|
+
now_ts = datetime.now()
|
|
105
|
+
for parameter, value in values.items():
|
|
106
|
+
dpk = DataPointKey(
|
|
107
|
+
interface_id=self._interface_id,
|
|
108
|
+
channel_address=channel_address,
|
|
109
|
+
paramset_key=paramset_key,
|
|
110
|
+
parameter=parameter,
|
|
111
|
+
)
|
|
112
|
+
self._last_send_command[dpk] = CachedCommand(value=value, sent_at=now_ts)
|
|
113
|
+
dpk_values.add((dpk, value))
|
|
114
|
+
return dpk_values
|
|
115
|
+
|
|
116
|
+
def add_set_value(
|
|
117
|
+
self,
|
|
118
|
+
*,
|
|
119
|
+
channel_address: str,
|
|
120
|
+
parameter: str,
|
|
121
|
+
value: Any,
|
|
122
|
+
) -> set[DP_KEY_VALUE]:
|
|
123
|
+
"""Add data from set value command."""
|
|
124
|
+
if parameter in CONVERTABLE_PARAMETERS:
|
|
125
|
+
return self.add_combined_parameter(
|
|
126
|
+
parameter=parameter, channel_address=channel_address, combined_parameter=value
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Cleanup expired entries when tracker size exceeds threshold
|
|
130
|
+
if len(self._last_send_command) > LAST_COMMAND_SEND_TRACKER_CLEANUP_THRESHOLD:
|
|
131
|
+
self.cleanup_expired()
|
|
132
|
+
|
|
133
|
+
# Enforce hard size limit
|
|
134
|
+
self._enforce_size_limit()
|
|
135
|
+
|
|
136
|
+
now_ts = datetime.now()
|
|
137
|
+
dpk = DataPointKey(
|
|
138
|
+
interface_id=self._interface_id,
|
|
139
|
+
channel_address=channel_address,
|
|
140
|
+
paramset_key=ParamsetKey.VALUES,
|
|
141
|
+
parameter=parameter,
|
|
142
|
+
)
|
|
143
|
+
self._last_send_command[dpk] = CachedCommand(value=value, sent_at=now_ts)
|
|
144
|
+
return {(dpk, value)}
|
|
145
|
+
|
|
146
|
+
def cleanup_expired(self, *, max_age: int = LAST_COMMAND_SEND_STORE_TIMEOUT) -> int:
|
|
147
|
+
"""
|
|
148
|
+
Remove expired command tracker entries.
|
|
149
|
+
|
|
150
|
+
Return the number of entries removed.
|
|
151
|
+
|
|
152
|
+
Two-pass algorithm (safer than deleting during iteration):
|
|
153
|
+
1. First pass: Collect keys of expired entries into a list
|
|
154
|
+
2. Second pass: Delete collected keys from the dictionary
|
|
155
|
+
|
|
156
|
+
This avoids "dictionary changed size during iteration" errors.
|
|
157
|
+
"""
|
|
158
|
+
# Pass 1: Identify expired entries without modifying the dict
|
|
159
|
+
expired_keys = [
|
|
160
|
+
dpk
|
|
161
|
+
for dpk, cached in self._last_send_command.items()
|
|
162
|
+
if not changed_within_seconds(last_change=cached.sent_at, max_age=max_age)
|
|
163
|
+
]
|
|
164
|
+
# Pass 2: Delete expired entries
|
|
165
|
+
for dpk in expired_keys:
|
|
166
|
+
del self._last_send_command[dpk]
|
|
167
|
+
# Track evictions via local counter
|
|
168
|
+
if expired_keys:
|
|
169
|
+
self._stats.record_eviction(count=len(expired_keys))
|
|
170
|
+
return len(expired_keys)
|
|
171
|
+
|
|
172
|
+
def clear(self) -> None:
|
|
173
|
+
"""Clear all tracked command entries."""
|
|
174
|
+
self._last_send_command.clear()
|
|
175
|
+
|
|
176
|
+
def get_last_value_send(self, *, dpk: DataPointKey, max_age: int = LAST_COMMAND_SEND_STORE_TIMEOUT) -> Any:
|
|
177
|
+
"""Return the last send values."""
|
|
178
|
+
if cached := self._last_send_command.get(dpk):
|
|
179
|
+
if cached.sent_at and changed_within_seconds(last_change=cached.sent_at, max_age=max_age):
|
|
180
|
+
return cached.value
|
|
181
|
+
self.remove_last_value_send(
|
|
182
|
+
dpk=dpk,
|
|
183
|
+
max_age=max_age,
|
|
184
|
+
)
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
def remove_last_value_send(
|
|
188
|
+
self,
|
|
189
|
+
*,
|
|
190
|
+
dpk: DataPointKey,
|
|
191
|
+
value: Any = None,
|
|
192
|
+
max_age: int = LAST_COMMAND_SEND_STORE_TIMEOUT,
|
|
193
|
+
) -> None:
|
|
194
|
+
"""Remove the last send value."""
|
|
195
|
+
if (cached := self._last_send_command.get(dpk)) is not None and (
|
|
196
|
+
not changed_within_seconds(last_change=cached.sent_at, max_age=max_age)
|
|
197
|
+
or (value is not None and cached.value == value)
|
|
198
|
+
):
|
|
199
|
+
del self._last_send_command[dpk]
|
|
200
|
+
|
|
201
|
+
def _enforce_size_limit(self) -> None:
|
|
202
|
+
"""
|
|
203
|
+
Enforce size limits on the tracker to prevent unbounded growth.
|
|
204
|
+
|
|
205
|
+
LRU-style eviction algorithm:
|
|
206
|
+
When tracker reaches MAX_SIZE, evict the oldest 20% of entries.
|
|
207
|
+
The 20% threshold is a heuristic that balances:
|
|
208
|
+
- Memory reclamation (enough entries removed to be meaningful)
|
|
209
|
+
- Performance (not called too frequently)
|
|
210
|
+
- Data retention (most recent entries are preserved)
|
|
211
|
+
|
|
212
|
+
Warning hysteresis:
|
|
213
|
+
The _warning_logged flag prevents log spam when tracker size oscillates
|
|
214
|
+
near the warning threshold. Warning is logged once when threshold is
|
|
215
|
+
exceeded, then reset only when size drops below threshold.
|
|
216
|
+
"""
|
|
217
|
+
current_size = len(self._last_send_command)
|
|
218
|
+
|
|
219
|
+
# Warning with hysteresis: log once when crossing threshold, reset when below
|
|
220
|
+
if current_size >= COMMAND_TRACKER_WARNING_THRESHOLD and not self._warning_logged:
|
|
221
|
+
_LOGGER.warning( # i18n-log: ignore
|
|
222
|
+
"CommandTracker for %s approaching size limit: %d/%d entries",
|
|
223
|
+
self._interface_id,
|
|
224
|
+
current_size,
|
|
225
|
+
COMMAND_TRACKER_MAX_SIZE,
|
|
226
|
+
)
|
|
227
|
+
self._warning_logged = True
|
|
228
|
+
elif current_size < COMMAND_TRACKER_WARNING_THRESHOLD:
|
|
229
|
+
# Reset warning flag when tracker shrinks below threshold
|
|
230
|
+
self._warning_logged = False
|
|
231
|
+
|
|
232
|
+
# Hard limit enforcement with LRU eviction
|
|
233
|
+
if current_size >= COMMAND_TRACKER_MAX_SIZE:
|
|
234
|
+
# Sort entries by timestamp (oldest first) for LRU eviction
|
|
235
|
+
sorted_entries = sorted(
|
|
236
|
+
self._last_send_command.items(),
|
|
237
|
+
key=lambda item: item[1].sent_at,
|
|
238
|
+
)
|
|
239
|
+
# Remove oldest 20% of entries (at least 1)
|
|
240
|
+
remove_count = max(1, current_size // 5)
|
|
241
|
+
for dpk, _ in sorted_entries[:remove_count]:
|
|
242
|
+
del self._last_send_command[dpk]
|
|
243
|
+
# Track evictions via local counter
|
|
244
|
+
self._stats.record_eviction(count=remove_count)
|
|
245
|
+
_LOGGER.debug(
|
|
246
|
+
"CommandTracker for %s evicted %d oldest entries (size was %d)",
|
|
247
|
+
self._interface_id,
|
|
248
|
+
remove_count,
|
|
249
|
+
current_size,
|
|
250
|
+
)
|