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.
Files changed (188) hide show
  1. aiohomematic/__init__.py +110 -0
  2. aiohomematic/_log_context_protocol.py +29 -0
  3. aiohomematic/api.py +410 -0
  4. aiohomematic/async_support.py +250 -0
  5. aiohomematic/backend_detection.py +462 -0
  6. aiohomematic/central/__init__.py +103 -0
  7. aiohomematic/central/async_rpc_server.py +760 -0
  8. aiohomematic/central/central_unit.py +1152 -0
  9. aiohomematic/central/config.py +463 -0
  10. aiohomematic/central/config_builder.py +772 -0
  11. aiohomematic/central/connection_state.py +160 -0
  12. aiohomematic/central/coordinators/__init__.py +38 -0
  13. aiohomematic/central/coordinators/cache.py +414 -0
  14. aiohomematic/central/coordinators/client.py +480 -0
  15. aiohomematic/central/coordinators/connection_recovery.py +1141 -0
  16. aiohomematic/central/coordinators/device.py +1166 -0
  17. aiohomematic/central/coordinators/event.py +514 -0
  18. aiohomematic/central/coordinators/hub.py +532 -0
  19. aiohomematic/central/decorators.py +184 -0
  20. aiohomematic/central/device_registry.py +229 -0
  21. aiohomematic/central/events/__init__.py +104 -0
  22. aiohomematic/central/events/bus.py +1392 -0
  23. aiohomematic/central/events/integration.py +424 -0
  24. aiohomematic/central/events/types.py +194 -0
  25. aiohomematic/central/health.py +762 -0
  26. aiohomematic/central/rpc_server.py +353 -0
  27. aiohomematic/central/scheduler.py +794 -0
  28. aiohomematic/central/state_machine.py +391 -0
  29. aiohomematic/client/__init__.py +203 -0
  30. aiohomematic/client/_rpc_errors.py +187 -0
  31. aiohomematic/client/backends/__init__.py +48 -0
  32. aiohomematic/client/backends/base.py +335 -0
  33. aiohomematic/client/backends/capabilities.py +138 -0
  34. aiohomematic/client/backends/ccu.py +487 -0
  35. aiohomematic/client/backends/factory.py +116 -0
  36. aiohomematic/client/backends/homegear.py +294 -0
  37. aiohomematic/client/backends/json_ccu.py +252 -0
  38. aiohomematic/client/backends/protocol.py +316 -0
  39. aiohomematic/client/ccu.py +1857 -0
  40. aiohomematic/client/circuit_breaker.py +459 -0
  41. aiohomematic/client/config.py +64 -0
  42. aiohomematic/client/handlers/__init__.py +40 -0
  43. aiohomematic/client/handlers/backup.py +157 -0
  44. aiohomematic/client/handlers/base.py +79 -0
  45. aiohomematic/client/handlers/device_ops.py +1085 -0
  46. aiohomematic/client/handlers/firmware.py +144 -0
  47. aiohomematic/client/handlers/link_mgmt.py +199 -0
  48. aiohomematic/client/handlers/metadata.py +436 -0
  49. aiohomematic/client/handlers/programs.py +144 -0
  50. aiohomematic/client/handlers/sysvars.py +100 -0
  51. aiohomematic/client/interface_client.py +1304 -0
  52. aiohomematic/client/json_rpc.py +2068 -0
  53. aiohomematic/client/request_coalescer.py +282 -0
  54. aiohomematic/client/rpc_proxy.py +629 -0
  55. aiohomematic/client/state_machine.py +324 -0
  56. aiohomematic/const.py +2207 -0
  57. aiohomematic/context.py +275 -0
  58. aiohomematic/converter.py +270 -0
  59. aiohomematic/decorators.py +390 -0
  60. aiohomematic/exceptions.py +185 -0
  61. aiohomematic/hmcli.py +997 -0
  62. aiohomematic/i18n.py +193 -0
  63. aiohomematic/interfaces/__init__.py +407 -0
  64. aiohomematic/interfaces/central.py +1067 -0
  65. aiohomematic/interfaces/client.py +1096 -0
  66. aiohomematic/interfaces/coordinators.py +63 -0
  67. aiohomematic/interfaces/model.py +1921 -0
  68. aiohomematic/interfaces/operations.py +217 -0
  69. aiohomematic/logging_context.py +134 -0
  70. aiohomematic/metrics/__init__.py +125 -0
  71. aiohomematic/metrics/_protocols.py +140 -0
  72. aiohomematic/metrics/aggregator.py +534 -0
  73. aiohomematic/metrics/dataclasses.py +489 -0
  74. aiohomematic/metrics/emitter.py +292 -0
  75. aiohomematic/metrics/events.py +183 -0
  76. aiohomematic/metrics/keys.py +300 -0
  77. aiohomematic/metrics/observer.py +563 -0
  78. aiohomematic/metrics/stats.py +172 -0
  79. aiohomematic/model/__init__.py +189 -0
  80. aiohomematic/model/availability.py +65 -0
  81. aiohomematic/model/calculated/__init__.py +89 -0
  82. aiohomematic/model/calculated/climate.py +276 -0
  83. aiohomematic/model/calculated/data_point.py +315 -0
  84. aiohomematic/model/calculated/field.py +147 -0
  85. aiohomematic/model/calculated/operating_voltage_level.py +286 -0
  86. aiohomematic/model/calculated/support.py +232 -0
  87. aiohomematic/model/custom/__init__.py +214 -0
  88. aiohomematic/model/custom/capabilities/__init__.py +67 -0
  89. aiohomematic/model/custom/capabilities/climate.py +41 -0
  90. aiohomematic/model/custom/capabilities/light.py +87 -0
  91. aiohomematic/model/custom/capabilities/lock.py +44 -0
  92. aiohomematic/model/custom/capabilities/siren.py +63 -0
  93. aiohomematic/model/custom/climate.py +1130 -0
  94. aiohomematic/model/custom/cover.py +722 -0
  95. aiohomematic/model/custom/data_point.py +360 -0
  96. aiohomematic/model/custom/definition.py +300 -0
  97. aiohomematic/model/custom/field.py +89 -0
  98. aiohomematic/model/custom/light.py +1174 -0
  99. aiohomematic/model/custom/lock.py +322 -0
  100. aiohomematic/model/custom/mixins.py +445 -0
  101. aiohomematic/model/custom/profile.py +945 -0
  102. aiohomematic/model/custom/registry.py +251 -0
  103. aiohomematic/model/custom/siren.py +462 -0
  104. aiohomematic/model/custom/switch.py +195 -0
  105. aiohomematic/model/custom/text_display.py +289 -0
  106. aiohomematic/model/custom/valve.py +78 -0
  107. aiohomematic/model/data_point.py +1416 -0
  108. aiohomematic/model/device.py +1840 -0
  109. aiohomematic/model/event.py +216 -0
  110. aiohomematic/model/generic/__init__.py +327 -0
  111. aiohomematic/model/generic/action.py +40 -0
  112. aiohomematic/model/generic/action_select.py +62 -0
  113. aiohomematic/model/generic/binary_sensor.py +30 -0
  114. aiohomematic/model/generic/button.py +31 -0
  115. aiohomematic/model/generic/data_point.py +177 -0
  116. aiohomematic/model/generic/dummy.py +150 -0
  117. aiohomematic/model/generic/number.py +76 -0
  118. aiohomematic/model/generic/select.py +56 -0
  119. aiohomematic/model/generic/sensor.py +76 -0
  120. aiohomematic/model/generic/switch.py +54 -0
  121. aiohomematic/model/generic/text.py +33 -0
  122. aiohomematic/model/hub/__init__.py +100 -0
  123. aiohomematic/model/hub/binary_sensor.py +24 -0
  124. aiohomematic/model/hub/button.py +28 -0
  125. aiohomematic/model/hub/connectivity.py +190 -0
  126. aiohomematic/model/hub/data_point.py +342 -0
  127. aiohomematic/model/hub/hub.py +864 -0
  128. aiohomematic/model/hub/inbox.py +135 -0
  129. aiohomematic/model/hub/install_mode.py +393 -0
  130. aiohomematic/model/hub/metrics.py +208 -0
  131. aiohomematic/model/hub/number.py +42 -0
  132. aiohomematic/model/hub/select.py +52 -0
  133. aiohomematic/model/hub/sensor.py +37 -0
  134. aiohomematic/model/hub/switch.py +43 -0
  135. aiohomematic/model/hub/text.py +30 -0
  136. aiohomematic/model/hub/update.py +221 -0
  137. aiohomematic/model/support.py +592 -0
  138. aiohomematic/model/update.py +140 -0
  139. aiohomematic/model/week_profile.py +1827 -0
  140. aiohomematic/property_decorators.py +719 -0
  141. aiohomematic/py.typed +0 -0
  142. aiohomematic/rega_scripts/accept_device_in_inbox.fn +51 -0
  143. aiohomematic/rega_scripts/create_backup_start.fn +28 -0
  144. aiohomematic/rega_scripts/create_backup_status.fn +89 -0
  145. aiohomematic/rega_scripts/fetch_all_device_data.fn +97 -0
  146. aiohomematic/rega_scripts/get_backend_info.fn +25 -0
  147. aiohomematic/rega_scripts/get_inbox_devices.fn +61 -0
  148. aiohomematic/rega_scripts/get_program_descriptions.fn +31 -0
  149. aiohomematic/rega_scripts/get_serial.fn +44 -0
  150. aiohomematic/rega_scripts/get_service_messages.fn +83 -0
  151. aiohomematic/rega_scripts/get_system_update_info.fn +39 -0
  152. aiohomematic/rega_scripts/get_system_variable_descriptions.fn +31 -0
  153. aiohomematic/rega_scripts/set_program_state.fn +17 -0
  154. aiohomematic/rega_scripts/set_system_variable.fn +19 -0
  155. aiohomematic/rega_scripts/trigger_firmware_update.fn +67 -0
  156. aiohomematic/schemas.py +256 -0
  157. aiohomematic/store/__init__.py +55 -0
  158. aiohomematic/store/dynamic/__init__.py +43 -0
  159. aiohomematic/store/dynamic/command.py +250 -0
  160. aiohomematic/store/dynamic/data.py +175 -0
  161. aiohomematic/store/dynamic/details.py +187 -0
  162. aiohomematic/store/dynamic/ping_pong.py +416 -0
  163. aiohomematic/store/persistent/__init__.py +71 -0
  164. aiohomematic/store/persistent/base.py +285 -0
  165. aiohomematic/store/persistent/device.py +233 -0
  166. aiohomematic/store/persistent/incident.py +380 -0
  167. aiohomematic/store/persistent/paramset.py +241 -0
  168. aiohomematic/store/persistent/session.py +556 -0
  169. aiohomematic/store/serialization.py +150 -0
  170. aiohomematic/store/storage.py +689 -0
  171. aiohomematic/store/types.py +526 -0
  172. aiohomematic/store/visibility/__init__.py +40 -0
  173. aiohomematic/store/visibility/parser.py +141 -0
  174. aiohomematic/store/visibility/registry.py +722 -0
  175. aiohomematic/store/visibility/rules.py +307 -0
  176. aiohomematic/strings.json +237 -0
  177. aiohomematic/support.py +706 -0
  178. aiohomematic/tracing.py +236 -0
  179. aiohomematic/translations/de.json +237 -0
  180. aiohomematic/translations/en.json +237 -0
  181. aiohomematic/type_aliases.py +51 -0
  182. aiohomematic/validator.py +128 -0
  183. aiohomematic-2026.1.29.dist-info/METADATA +296 -0
  184. aiohomematic-2026.1.29.dist-info/RECORD +188 -0
  185. aiohomematic-2026.1.29.dist-info/WHEEL +5 -0
  186. aiohomematic-2026.1.29.dist-info/entry_points.txt +2 -0
  187. aiohomematic-2026.1.29.dist-info/licenses/LICENSE +21 -0
  188. aiohomematic-2026.1.29.dist-info/top_level.txt +1 -0
@@ -0,0 +1,147 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """
4
+ Descriptor-based field definitions for calculated data points.
5
+
6
+ This module provides a declarative way to define data point fields,
7
+ eliminating boilerplate in _post_init() methods.
8
+
9
+ Public API of this module is defined by __all__.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import TYPE_CHECKING, Any, Final, cast, overload
15
+
16
+ from aiohomematic.const import ParamsetKey
17
+ from aiohomematic.interfaces import GenericDataPointProtocolAny
18
+ from aiohomematic.property_decorators import DelegatedProperty
19
+
20
+ if TYPE_CHECKING:
21
+ from typing import Self
22
+
23
+ from aiohomematic.model.calculated import CalculatedDataPoint
24
+
25
+ __all__ = ["CalculatedDataPointField"]
26
+
27
+ # Key type for calculated data point dictionary
28
+ type _DataPointKey = tuple[str, ParamsetKey | None]
29
+
30
+
31
+ class CalculatedDataPointField[DataPointT: GenericDataPointProtocolAny]:
32
+ """
33
+ Descriptor for declarative calculated data point field definitions.
34
+
35
+ This descriptor eliminates the need for explicit _post_init()
36
+ boilerplate by lazily resolving data points on first access.
37
+
38
+ Usage:
39
+ class MyCalculatedSensor(CalculatedDataPoint):
40
+ # Simple field
41
+ _dp_wind_speed: Final = CalculatedDataPointField(
42
+ parameter=Parameter.WIND_SPEED,
43
+ paramset_key=ParamsetKey.VALUES,
44
+ dpt=DpSensor,
45
+ )
46
+
47
+ # Field with fallback parameters
48
+ _dp_temperature: Final = CalculatedDataPointField(
49
+ parameter=Parameter.TEMPERATURE,
50
+ paramset_key=ParamsetKey.VALUES,
51
+ dpt=DpSensor,
52
+ fallback_parameters=[Parameter.ACTUAL_TEMPERATURE],
53
+ )
54
+
55
+ # Field with device fallback (tries device address if not on channel)
56
+ _dp_low_bat_limit: Final = CalculatedDataPointField(
57
+ parameter=Parameter.LOW_BAT_LIMIT,
58
+ paramset_key=ParamsetKey.MASTER,
59
+ dpt=DpFloat,
60
+ use_device_fallback=True,
61
+ )
62
+
63
+ The descriptor:
64
+ - Resolves the data point from _data_points dict on each access (O(1) lookup)
65
+ - Tries fallback_parameters in order if primary parameter doesn't exist
66
+ - Tries device address if use_device_fallback=True and not found on channel
67
+ - Returns a DpDummy fallback if no data point exists
68
+ - Subscribes to data point updates automatically
69
+ - Provides correct type information to mypy
70
+ """
71
+
72
+ __slots__ = ("_parameter", "_paramset_key", "_data_point_type", "_fallback_parameters", "_use_device_fallback")
73
+
74
+ def __init__(
75
+ self,
76
+ *,
77
+ parameter: str,
78
+ paramset_key: ParamsetKey | None,
79
+ dpt: type[DataPointT],
80
+ fallback_parameters: list[str] | None = None,
81
+ use_device_fallback: bool = False,
82
+ ) -> None:
83
+ """
84
+ Initialize the calculated data point field descriptor.
85
+
86
+ Args:
87
+ parameter: The parameter name identifying this data point
88
+ paramset_key: The paramset key (VALUES, MASTER, etc.)
89
+ dpt: The expected data point type (e.g., DpSensor, DpFloat)
90
+ fallback_parameters: Optional list of fallback parameter names to try if primary not found
91
+ use_device_fallback: If True, try device address (channel 0) if not found on current channel
92
+
93
+ """
94
+ self._parameter: Final = parameter
95
+ self._paramset_key: Final = paramset_key
96
+ self._data_point_type: Final = dpt
97
+ self._fallback_parameters: Final = fallback_parameters or []
98
+ self._use_device_fallback: Final = use_device_fallback
99
+
100
+ @overload
101
+ def __get__(self, instance: None, owner: type) -> Self: ... # kwonly: disable
102
+
103
+ @overload
104
+ def __get__(self, instance: CalculatedDataPoint[Any], owner: type) -> DataPointT: ... # kwonly: disable
105
+
106
+ def __get__(self, instance: CalculatedDataPoint[Any] | None, owner: type) -> Self | DataPointT: # kwonly: disable
107
+ """
108
+ Get the data point for this field.
109
+
110
+ On class-level access (instance=None), returns the descriptor itself.
111
+ On instance access, looks up the data point from _data_points dict.
112
+ """
113
+ if instance is None:
114
+ return self # Class-level access returns descriptor
115
+
116
+ key: _DataPointKey = (self._parameter, self._paramset_key)
117
+
118
+ # Resolve from _data_points dict (O(1) lookup)
119
+ if found_dp := instance._data_points.get(key):
120
+ return cast(DataPointT, found_dp)
121
+
122
+ # Try primary parameter first, then fallbacks on current channel
123
+ for param in (self._parameter, *self._fallback_parameters):
124
+ if instance._channel.get_generic_data_point(parameter=param, paramset_key=self._paramset_key):
125
+ dp = instance._resolve_data_point(parameter=param, paramset_key=self._paramset_key)
126
+ instance._data_points[key] = dp
127
+ return cast(DataPointT, dp)
128
+
129
+ # Try device address (channel 0) if enabled
130
+ if self._use_device_fallback:
131
+ dp = instance._add_device_data_point(
132
+ channel_address=instance._channel.device.address,
133
+ parameter=self._parameter,
134
+ paramset_key=self._paramset_key,
135
+ dpt=self._data_point_type,
136
+ )
137
+ instance._data_points[key] = dp
138
+ return cast(DataPointT, dp)
139
+
140
+ # No data point found - resolve DpDummy for primary parameter
141
+ dp = instance._resolve_data_point(parameter=self._parameter, paramset_key=self._paramset_key)
142
+ instance._data_points[key] = dp
143
+ return cast(DataPointT, dp)
144
+
145
+ data_point_type: Final = DelegatedProperty[type[DataPointT]](path="_data_point_type")
146
+ parameter: Final = DelegatedProperty[str](path="_parameter")
147
+ paramset_key: Final = DelegatedProperty[ParamsetKey | None](path="_paramset_key")
@@ -0,0 +1,286 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """Module for calculating the operating voltage level in the sensor category."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass
9
+ from enum import StrEnum
10
+ import logging
11
+ from typing import Any, Final
12
+
13
+ from aiohomematic.const import CalculatedParameter, DataPointCategory, Parameter, ParameterType, ParamsetKey
14
+ from aiohomematic.interfaces import ChannelProtocol
15
+ from aiohomematic.model.calculated import CalculatedDataPoint
16
+ from aiohomematic.model.calculated.field import CalculatedDataPointField
17
+ from aiohomematic.model.calculated.support import calculate_operating_voltage_level
18
+ from aiohomematic.model.generic import DpFloat, DpSensor
19
+ from aiohomematic.property_decorators import state_property
20
+ from aiohomematic.support import element_matches_key, extract_exc_args
21
+
22
+ _BATTERY_QTY: Final = "Battery Qty"
23
+ _BATTERY_TYPE: Final = "Battery Type"
24
+ _LOW_BAT_LIMIT: Final = "Low Battery Limit"
25
+ _LOW_BAT_LIMIT_DEFAULT: Final = "Low Battery Limit Default"
26
+ _VOLTAGE_MAX: Final = "Voltage max"
27
+
28
+ _LOGGER: Final = logging.getLogger(__name__)
29
+
30
+
31
+ class OperatingVoltageLevel[SensorT: float | None](CalculatedDataPoint[SensorT]):
32
+ """Implementation of a calculated sensor for operating voltage level."""
33
+
34
+ __slots__ = (
35
+ "_battery_data",
36
+ "_low_bat_limit_default",
37
+ "_voltage_max",
38
+ )
39
+
40
+ _calculated_parameter = CalculatedParameter.OPERATING_VOLTAGE_LEVEL
41
+ _category = DataPointCategory.SENSOR
42
+
43
+ _dp_low_bat_limit: Final = CalculatedDataPointField(
44
+ parameter=Parameter.LOW_BAT_LIMIT,
45
+ paramset_key=ParamsetKey.MASTER,
46
+ dpt=DpFloat,
47
+ use_device_fallback=True,
48
+ )
49
+ _dp_operating_voltage: Final = CalculatedDataPointField(
50
+ parameter=Parameter.OPERATING_VOLTAGE,
51
+ paramset_key=ParamsetKey.VALUES,
52
+ dpt=DpSensor,
53
+ fallback_parameters=[Parameter.BATTERY_STATE],
54
+ )
55
+
56
+ def __init__(self, *, channel: ChannelProtocol) -> None:
57
+ """Initialize the data point."""
58
+ super().__init__(channel=channel)
59
+ self._type = ParameterType.FLOAT
60
+ self._unit = "%"
61
+
62
+ @staticmethod
63
+ def is_relevant_for_model(*, channel: ChannelProtocol) -> bool:
64
+ """Return if this calculated data point is relevant for the model."""
65
+ if element_matches_key(
66
+ search_elements=_IGNORE_OPERATING_VOLTAGE_LEVEL_MODELS, compare_with=channel.device.model
67
+ ):
68
+ return False
69
+ return element_matches_key(
70
+ search_elements=_OPERATING_VOLTAGE_LEVEL_MODELS.keys(), compare_with=channel.device.model
71
+ ) and (
72
+ (
73
+ channel.get_generic_data_point(
74
+ parameter=Parameter.OPERATING_VOLTAGE,
75
+ paramset_key=ParamsetKey.VALUES,
76
+ )
77
+ and channel.get_generic_data_point(parameter=Parameter.LOW_BAT_LIMIT, paramset_key=ParamsetKey.MASTER)
78
+ )
79
+ is not None
80
+ or (
81
+ channel.get_generic_data_point(
82
+ parameter=Parameter.BATTERY_STATE,
83
+ paramset_key=ParamsetKey.VALUES,
84
+ )
85
+ and channel.device.get_generic_data_point(
86
+ channel_address=channel.device.address,
87
+ parameter=Parameter.LOW_BAT_LIMIT,
88
+ paramset_key=ParamsetKey.MASTER,
89
+ )
90
+ )
91
+ is not None
92
+ )
93
+
94
+ @property
95
+ def _low_bat_limit(self) -> float | None:
96
+ """Return the min value."""
97
+ return (
98
+ float(self._dp_low_bat_limit.value)
99
+ if self._dp_low_bat_limit is not None and self._dp_low_bat_limit.value is not None
100
+ else None
101
+ )
102
+
103
+ @state_property
104
+ def additional_information(self) -> dict[str, Any]:
105
+ """Return additional information about the data point."""
106
+ ainfo = super().additional_information
107
+ if self._battery_data is not None:
108
+ ainfo.update(
109
+ {
110
+ _BATTERY_QTY: self._battery_data.quantity,
111
+ _BATTERY_TYPE: self._battery_data.battery,
112
+ _LOW_BAT_LIMIT: f"{self._low_bat_limit}V",
113
+ _LOW_BAT_LIMIT_DEFAULT: f"{self._low_bat_limit_default}V",
114
+ _VOLTAGE_MAX: f"{self._voltage_max}V",
115
+ }
116
+ )
117
+ return ainfo
118
+
119
+ @state_property
120
+ def value(self) -> float | None:
121
+ """Return the value."""
122
+ try:
123
+ return calculate_operating_voltage_level(
124
+ operating_voltage=self._dp_operating_voltage.value,
125
+ low_bat_limit=self._low_bat_limit_default,
126
+ voltage_max=self._voltage_max,
127
+ )
128
+ except Exception as exc:
129
+ _LOGGER.debug(
130
+ "OperatingVoltageLevel: Failed to calculate sensor for %s: %s",
131
+ self._channel.name,
132
+ extract_exc_args(exc=exc),
133
+ )
134
+ return None
135
+
136
+ def _post_init(self) -> None:
137
+ """Post action after initialisation of the data point fields."""
138
+ super()._post_init()
139
+
140
+ self._battery_data = _get_battery_data(model=self._channel.device.model)
141
+ self._low_bat_limit_default = (
142
+ float(self._dp_low_bat_limit.default)
143
+ if isinstance(self._dp_low_bat_limit, DpFloat) and self._dp_low_bat_limit.default is not None
144
+ else None
145
+ )
146
+ self._voltage_max = (
147
+ float(_BatteryVoltage[self._battery_data.battery] * self._battery_data.quantity)
148
+ if self._battery_data is not None
149
+ else None
150
+ )
151
+
152
+
153
+ class _BatteryType(StrEnum):
154
+ CR2032 = "CR2032"
155
+ LR44 = "LR44"
156
+ R03 = "AAA"
157
+ R14 = "BABY"
158
+ R6 = "AA"
159
+ UNKNOWN = "UNKNOWN"
160
+
161
+
162
+ _BatteryVoltage: Final[Mapping[_BatteryType, float]] = {
163
+ _BatteryType.CR2032: 3.0,
164
+ _BatteryType.LR44: 1.5,
165
+ _BatteryType.R03: 1.5,
166
+ _BatteryType.R14: 1.5,
167
+ _BatteryType.R6: 1.5,
168
+ }
169
+
170
+
171
+ @dataclass(frozen=True, kw_only=True, slots=True)
172
+ class _BatteryData:
173
+ model: str
174
+ battery: _BatteryType
175
+ quantity: int = 1
176
+
177
+
178
+ # This list is sorted. models with shorted model types are sorted to
179
+ _BATTERY_DATA: Final = (
180
+ # HM long model str
181
+ _BatteryData(model="HM-CC-RT-DN", battery=_BatteryType.R6, quantity=2),
182
+ _BatteryData(model="HM-Dis-EP-WM55", battery=_BatteryType.R03, quantity=2),
183
+ _BatteryData(model="HM-ES-TX-WM", battery=_BatteryType.R6, quantity=4),
184
+ _BatteryData(model="HM-OU-CFM-TW", battery=_BatteryType.R14, quantity=2),
185
+ _BatteryData(model="HM-PB-2-FM", battery=_BatteryType.R03, quantity=2),
186
+ _BatteryData(model="HM-PB-2-WM55", battery=_BatteryType.R03, quantity=2),
187
+ _BatteryData(model="HM-PB-6-WM55", battery=_BatteryType.R03, quantity=2),
188
+ _BatteryData(model="HM-PBI-4-FM", battery=_BatteryType.CR2032),
189
+ _BatteryData(model="HM-RC-4-2", battery=_BatteryType.R03),
190
+ _BatteryData(model="HM-RC-8", battery=_BatteryType.R03, quantity=2),
191
+ _BatteryData(model="HM-RC-Key4-3", battery=_BatteryType.R03),
192
+ _BatteryData(model="HM-SCI-3-FM", battery=_BatteryType.CR2032),
193
+ _BatteryData(model="HM-Sec-Key", battery=_BatteryType.R6, quantity=3),
194
+ _BatteryData(model="HM-Sec-MDIR-2", battery=_BatteryType.R6, quantity=3),
195
+ _BatteryData(model="HM-Sec-RHS", battery=_BatteryType.LR44, quantity=2),
196
+ _BatteryData(model="HM-Sec-SC-2", battery=_BatteryType.LR44, quantity=2),
197
+ _BatteryData(model="HM-Sec-SCo", battery=_BatteryType.R03),
198
+ _BatteryData(model="HM-Sec-SD-2", battery=_BatteryType.UNKNOWN),
199
+ _BatteryData(model="HM-Sec-Sir-WM", battery=_BatteryType.R14, quantity=2),
200
+ _BatteryData(model="HM-Sec-TiS", battery=_BatteryType.CR2032),
201
+ _BatteryData(model="HM-Sec-Win", battery=_BatteryType.UNKNOWN),
202
+ _BatteryData(model="HM-Sen-MDIR-O-2", battery=_BatteryType.R6, quantity=3),
203
+ _BatteryData(model="HM-Sen-MDIR-SM", battery=_BatteryType.R6, quantity=3),
204
+ _BatteryData(model="HM-Sen-MDIR-WM55", battery=_BatteryType.R03, quantity=2),
205
+ _BatteryData(model="HM-SwI-3-FM", battery=_BatteryType.CR2032),
206
+ _BatteryData(model="HM-TC-IT-WM-W-EU", battery=_BatteryType.R03, quantity=2),
207
+ _BatteryData(model="HM-WDS10-TH-O", battery=_BatteryType.R6, quantity=2),
208
+ _BatteryData(model="HM-WDS30-OT2-SM", battery=_BatteryType.R6, quantity=2),
209
+ _BatteryData(model="HM-WDS30-T-O", battery=_BatteryType.R03, quantity=2),
210
+ _BatteryData(model="HM-WDS40-TH-I", battery=_BatteryType.R6, quantity=2),
211
+ # HM short model str
212
+ _BatteryData(model="HM-Sec-SD", battery=_BatteryType.R6, quantity=3),
213
+ # HmIP model > 4
214
+ _BatteryData(model="HmIP-ASIR-O", battery=_BatteryType.UNKNOWN),
215
+ _BatteryData(model="HmIP-DSD-PCB", battery=_BatteryType.R03, quantity=2),
216
+ _BatteryData(model="HmIP-PCBS-BAT", battery=_BatteryType.UNKNOWN),
217
+ _BatteryData(model="HmIP-SMI55", battery=_BatteryType.R03, quantity=2),
218
+ _BatteryData(model="HmIP-SMO230", battery=_BatteryType.UNKNOWN),
219
+ _BatteryData(model="HmIP-STE2-PCB", battery=_BatteryType.R6, quantity=2),
220
+ _BatteryData(model="HmIP-SWDO-I", battery=_BatteryType.R03, quantity=2),
221
+ _BatteryData(model="HmIP-SWDO-PL", battery=_BatteryType.R03, quantity=2),
222
+ _BatteryData(model="HmIP-WTH-B-2", battery=_BatteryType.R6, quantity=2),
223
+ _BatteryData(model="HmIP-eTRV-CL", battery=_BatteryType.R6, quantity=4),
224
+ # HmIP model 4
225
+ _BatteryData(model="ELV-SH-SW1-BAT", battery=_BatteryType.R6, quantity=2),
226
+ _BatteryData(model="ELV-SH-TACO", battery=_BatteryType.R03, quantity=1),
227
+ _BatteryData(model="HmIP-ASIR", battery=_BatteryType.R6, quantity=3),
228
+ _BatteryData(model="HmIP-FCI1", battery=_BatteryType.CR2032),
229
+ _BatteryData(model="HmIP-FCI6", battery=_BatteryType.R03),
230
+ _BatteryData(model="HmIP-MP3P", battery=_BatteryType.R14, quantity=2),
231
+ _BatteryData(model="HmIP-RCB1", battery=_BatteryType.R03, quantity=2),
232
+ _BatteryData(model="HmIP-SPDR", battery=_BatteryType.R6, quantity=2),
233
+ _BatteryData(model="HmIP-STHD", battery=_BatteryType.R03, quantity=2),
234
+ _BatteryData(model="HmIP-STHO", battery=_BatteryType.R6, quantity=2),
235
+ _BatteryData(model="HmIP-SWDM", battery=_BatteryType.R03, quantity=2),
236
+ _BatteryData(model="HmIP-SWDO", battery=_BatteryType.R03),
237
+ _BatteryData(model="HmIP-SWSD", battery=_BatteryType.UNKNOWN),
238
+ _BatteryData(model="HmIP-eTRV", battery=_BatteryType.R6, quantity=2),
239
+ # HmIP model 3
240
+ _BatteryData(model="ELV-SH-CTH", battery=_BatteryType.CR2032),
241
+ _BatteryData(model="ELV-SH-WSM", battery=_BatteryType.R6, quantity=2),
242
+ _BatteryData(model="HmIP-DBB", battery=_BatteryType.R03),
243
+ _BatteryData(model="HmIP-DLD", battery=_BatteryType.R6, quantity=3),
244
+ _BatteryData(model="HmIP-DLS", battery=_BatteryType.CR2032),
245
+ _BatteryData(model="HmIP-ESI", battery=_BatteryType.R6, quantity=2),
246
+ _BatteryData(model="HmIP-KRC", battery=_BatteryType.R03),
247
+ _BatteryData(model="HmIP-RC8", battery=_BatteryType.R03, quantity=2),
248
+ _BatteryData(model="HmIP-SAM", battery=_BatteryType.R6, quantity=2),
249
+ _BatteryData(model="HmIP-SCI", battery=_BatteryType.R03, quantity=2),
250
+ _BatteryData(model="HmIP-SLO", battery=_BatteryType.R6, quantity=2),
251
+ _BatteryData(model="HmIP-SMI", battery=_BatteryType.R6, quantity=2),
252
+ _BatteryData(model="HmIP-SMO", battery=_BatteryType.R6, quantity=2),
253
+ _BatteryData(model="HmIP-SPI", battery=_BatteryType.R6, quantity=2),
254
+ _BatteryData(model="HmIP-SRH", battery=_BatteryType.R03),
255
+ _BatteryData(model="HmIP-STH", battery=_BatteryType.R03, quantity=2),
256
+ _BatteryData(model="HmIP-STV", battery=_BatteryType.R03, quantity=2),
257
+ _BatteryData(model="HmIP-SWD", battery=_BatteryType.R03, quantity=2),
258
+ _BatteryData(model="HmIP-SWO", battery=_BatteryType.R6, quantity=3),
259
+ _BatteryData(model="HmIP-WGC", battery=_BatteryType.R6, quantity=2),
260
+ _BatteryData(model="HmIP-WKP", battery=_BatteryType.R03, quantity=2),
261
+ _BatteryData(model="HmIP-WRC", battery=_BatteryType.R03, quantity=2),
262
+ _BatteryData(model="HmIP-WSM", battery=_BatteryType.R6, quantity=2),
263
+ _BatteryData(model="HmIP-WTH", battery=_BatteryType.R03, quantity=2),
264
+ )
265
+
266
+ _OPERATING_VOLTAGE_LEVEL_MODELS: Final[Mapping[str, _BatteryData]] = {
267
+ battery.model: battery for battery in _BATTERY_DATA if battery.battery != _BatteryType.UNKNOWN
268
+ }
269
+
270
+ _IGNORE_OPERATING_VOLTAGE_LEVEL_MODELS: Final[tuple[str, ...]] = tuple(
271
+ [battery.model for battery in _BATTERY_DATA if battery.battery == _BatteryType.UNKNOWN]
272
+ )
273
+
274
+
275
+ def _get_battery_data(*, model: str) -> _BatteryData | None:
276
+ """Return the battery data by model."""
277
+ model_l = model.lower()
278
+ for battery_data in _OPERATING_VOLTAGE_LEVEL_MODELS.values():
279
+ if battery_data.model.lower() == model_l:
280
+ return battery_data
281
+
282
+ for battery_data in _OPERATING_VOLTAGE_LEVEL_MODELS.values():
283
+ if model_l.startswith(battery_data.model.lower()):
284
+ return battery_data
285
+
286
+ return None
@@ -0,0 +1,232 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """
4
+ A number of functions used to calculate values based on existing data.
5
+
6
+ Climate related formula are based on:
7
+ - thermal comfort (https://github.com/dolezsa/thermal_comfort) ground works.
8
+ - https://gist.github.com/E3V3A/8f9f0aa18380d4ab2546cd50b725a219
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import math
15
+ from typing import Final
16
+
17
+ from aiohomematic.support import extract_exc_args
18
+
19
+ _DEFAULT_PRESSURE_HPA: Final = 1013.25
20
+ _LOGGER: Final = logging.getLogger(__name__)
21
+
22
+
23
+ def calculate_dew_point_spread(*, temperature: float, humidity: int) -> float | None:
24
+ """
25
+ Calculate the dew point spread.
26
+
27
+ Dew point spread = Difference between current air temperature and dew point.
28
+ Specifies the safety margin against condensation(K).
29
+ """
30
+ if dew_point := calculate_dew_point(temperature=temperature, humidity=humidity):
31
+ return round(temperature - dew_point, 2)
32
+ return None
33
+
34
+
35
+ def calculate_enthalpy(
36
+ *, temperature: float, humidity: int, pressure_hPa: float = _DEFAULT_PRESSURE_HPA
37
+ ) -> float | None:
38
+ """
39
+ Calculate the enthalpy based on temperature and humidity.
40
+
41
+ Calculates the specific enthalpy of humid air in kJ/kg (relative to dry air).
42
+ temperature: Air temperature in °C
43
+ humidity: Relative humidity in %
44
+ pressure_hPa: Air pressure (default: 1013.25 hPa)
45
+
46
+ """
47
+ # Saturation vapor pressure according to Magnus in hPa
48
+ e_s = 6.112 * math.exp((17.62 * temperature) / (243.12 + temperature))
49
+ e = humidity / 100.0 * e_s # aktueller Dampfdruck in hPa
50
+
51
+ # Mixing ratio (g water / kg dry air)
52
+ r = 622 * e / (pressure_hPa - e)
53
+
54
+ # Specific enthalpy (kJ/kg dry air)
55
+ h = 1.006 * temperature + r * (2501 + 1.86 * temperature) / 1000 # in kJ/kg
56
+ return round(h, 2)
57
+
58
+
59
+ def _calculate_heat_index(*, temperature: float, humidity: int) -> float:
60
+ """
61
+ Calculate the Heat Index (feels like temperature) based on the NOAA equation.
62
+
63
+ References:
64
+ [1] https://en.wikipedia.org/wiki/Heat_index
65
+ [2] http://www.wpc.ncep.noaa.gov/html/heatindex_equation.shtml
66
+ [3] https://github.com/geanders/weathermetrics/blob/master/R/heat_index.R
67
+ [4] https://www.ncbi.nlm.nih.gov/pmc/articles/PMC3801457/
68
+
69
+ """
70
+ # SI units (Celsius)
71
+ c1 = -8.78469475556
72
+ c2 = 1.61139411
73
+ c3 = 2.33854883889
74
+ c4 = -0.14611605
75
+ c5 = -0.012308094
76
+ c6 = -0.0164248277778
77
+ c7 = 0.002211732
78
+ c8 = 0.00072546
79
+ c9 = -0.000003582
80
+
81
+ temperature_fahrenheit = (temperature * 9 / 5) + 32
82
+ heat_index_fahrenheit = 0.5 * (
83
+ temperature_fahrenheit + 61.0 + (temperature_fahrenheit - 68.0) * 1.2 + humidity * 0.094
84
+ )
85
+
86
+ if ((heat_index_fahrenheit + temperature_fahrenheit) / 2) >= 80: # [°F]
87
+ # temperature > 27C and humidity > 40 %
88
+ heat_index_celsius = math.fsum(
89
+ [
90
+ c1,
91
+ c2 * temperature,
92
+ c3 * humidity,
93
+ c4 * temperature * humidity,
94
+ c5 * temperature**2,
95
+ c6 * humidity**2,
96
+ c7 * temperature**2 * humidity,
97
+ c8 * temperature * humidity**2,
98
+ c9 * temperature**2 * humidity**2,
99
+ ]
100
+ )
101
+ else:
102
+ heat_index_celsius = (heat_index_fahrenheit - 32) * 5 / 9
103
+
104
+ return heat_index_celsius
105
+
106
+
107
+ def _calculate_wind_chill(*, temperature: float, wind_speed: float) -> float | None:
108
+ """
109
+ Calculate the Wind Chill (feels like temperature) based on NOAA.
110
+
111
+ References:
112
+ [1] https://en.wikipedia.org/wiki/Wind_chill
113
+ [2] https://www.wpc.ncep.noaa.gov/html/windchill.shtml
114
+
115
+ """
116
+ # Wind Chill Temperature is only defined for temperatures at or below 10°C and wind speeds above 4.8 Km/h.
117
+ if temperature > 10 or wind_speed <= 4.8: # if temperature > 50 or wind_speed <= 3: # (°F, Mph)
118
+ return None
119
+
120
+ return float(13.12 + (0.6215 * temperature) - 11.37 * wind_speed**0.16 + 0.3965 * temperature * wind_speed**0.16)
121
+
122
+
123
+ def calculate_vapor_concentration(*, temperature: float, humidity: int) -> float | None:
124
+ """Calculate the vapor concentration."""
125
+ try:
126
+ abs_temperature = temperature + 273.15
127
+ vapor_concentration = 6.112
128
+ vapor_concentration *= math.exp((17.67 * temperature) / (243.5 + temperature))
129
+ vapor_concentration *= humidity
130
+ vapor_concentration *= 2.1674
131
+ vapor_concentration /= abs_temperature
132
+
133
+ return round(vapor_concentration, 2)
134
+ except ValueError as verr:
135
+ _LOGGER.debug(
136
+ "Unable to calculate 'vapor concentration' with temperature: %s, humidity: %s (%s)",
137
+ temperature,
138
+ humidity,
139
+ extract_exc_args(exc=verr),
140
+ )
141
+ return None
142
+
143
+
144
+ def calculate_apparent_temperature(*, temperature: float, humidity: int, wind_speed: float) -> float | None:
145
+ """Calculate the apparent temperature based on NOAA."""
146
+ try:
147
+ if temperature <= 10 and wind_speed > 4.8:
148
+ # Wind Chill for low temp cases (and wind)
149
+ apparent_temperature = _calculate_wind_chill(temperature=temperature, wind_speed=wind_speed)
150
+ elif temperature >= 26.7:
151
+ # Heat Index for High temp cases
152
+ apparent_temperature = _calculate_heat_index(temperature=temperature, humidity=humidity)
153
+ else:
154
+ apparent_temperature = temperature
155
+
156
+ return round(apparent_temperature, 1) # type: ignore[arg-type]
157
+ except ValueError as verr:
158
+ if temperature == 0.0 and humidity == 0:
159
+ return 0.0
160
+ _LOGGER.debug(
161
+ "Unable to calculate 'apparent temperature' with temperature: %s, humidity: %s (%s)",
162
+ temperature,
163
+ humidity,
164
+ extract_exc_args(exc=verr),
165
+ )
166
+ return None
167
+
168
+
169
+ def calculate_dew_point(*, temperature: float, humidity: int) -> float | None:
170
+ """Calculate the dew point."""
171
+ try:
172
+ a0 = 373.15 / (273.15 + temperature)
173
+ s = -7.90298 * (a0 - 1)
174
+ s += 5.02808 * math.log(a0, 10)
175
+ s += -1.3816e-7 * (pow(10, (11.344 * (1 - 1 / a0))) - 1)
176
+ s += 8.1328e-3 * (pow(10, (-3.49149 * (a0 - 1))) - 1)
177
+ s += math.log(1013.246, 10)
178
+ vp = pow(10, s - 3) * humidity
179
+ td = math.log(vp / 0.61078)
180
+
181
+ return round((241.88 * td) / (17.558 - td), 1)
182
+ except ValueError as verr:
183
+ if temperature == 0.0 and humidity == 0:
184
+ return 0.0
185
+ _LOGGER.debug(
186
+ "Unable to calculate 'dew point' with temperature: %s, humidity: %s (%s)",
187
+ temperature,
188
+ humidity,
189
+ extract_exc_args(exc=verr),
190
+ )
191
+ return None
192
+
193
+
194
+ def calculate_frost_point(*, temperature: float, humidity: int) -> float | None:
195
+ """Calculate the frost point."""
196
+ try:
197
+ if (dew_point := calculate_dew_point(temperature=temperature, humidity=humidity)) is None:
198
+ return None
199
+ t = temperature + 273.15
200
+ td = dew_point + 273.15
201
+
202
+ return round((td + (2671.02 / ((2954.61 / t) + 2.193665 * math.log(t) - 13.3448)) - t) - 273.15, 1)
203
+ except ValueError as verr:
204
+ if temperature == 0.0 and humidity == 0:
205
+ return 0.0
206
+ _LOGGER.debug(
207
+ "Unable to calculate 'frost point' with temperature: %s, humidity: %s (%s)",
208
+ temperature,
209
+ humidity,
210
+ extract_exc_args(exc=verr),
211
+ )
212
+ return None
213
+
214
+
215
+ def calculate_operating_voltage_level(
216
+ *, operating_voltage: float | None, low_bat_limit: float | None, voltage_max: float | None
217
+ ) -> float | None:
218
+ """Return the operating voltage level."""
219
+ if operating_voltage is None or low_bat_limit is None or voltage_max is None:
220
+ return None
221
+ return max(
222
+ 0,
223
+ min(
224
+ 100,
225
+ float(
226
+ round(
227
+ ((float(operating_voltage) - low_bat_limit) / (voltage_max - low_bat_limit) * 100),
228
+ 1,
229
+ )
230
+ ),
231
+ ),
232
+ )