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,135 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """Module for hub inbox sensor."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from datetime import datetime
8
+ import logging
9
+ from typing import Final
10
+
11
+ from slugify import slugify
12
+
13
+ from aiohomematic.const import HUB_ADDRESS, INBOX_SENSOR_NAME, DataPointCategory, HubValueType, InboxDeviceData
14
+ from aiohomematic.interfaces import (
15
+ CentralInfoProtocol,
16
+ ChannelProtocol,
17
+ ConfigProviderProtocol,
18
+ EventBusProviderProtocol,
19
+ EventPublisherProtocol,
20
+ HubSensorDataPointProtocol,
21
+ ParameterVisibilityProviderProtocol,
22
+ ParamsetDescriptionProviderProtocol,
23
+ TaskSchedulerProtocol,
24
+ )
25
+ from aiohomematic.model.data_point import CallbackDataPoint
26
+ from aiohomematic.model.support import HubPathData, PathData, generate_unique_id, get_hub_data_point_name_data
27
+ from aiohomematic.property_decorators import DelegatedProperty, Kind, config_property, state_property
28
+ from aiohomematic.support import PayloadMixin
29
+
30
+ _LOGGER: Final = logging.getLogger(__name__)
31
+
32
+
33
+ class HmInboxSensor(CallbackDataPoint, HubSensorDataPointProtocol, PayloadMixin):
34
+ """Class for a Homematic inbox sensor."""
35
+
36
+ __slots__ = (
37
+ "_cached_device_count",
38
+ "_devices",
39
+ "_name_data",
40
+ "_state_uncertain",
41
+ )
42
+
43
+ _category = DataPointCategory.HUB_SENSOR
44
+ _enabled_default = True
45
+
46
+ def __init__(
47
+ self,
48
+ *,
49
+ config_provider: ConfigProviderProtocol,
50
+ central_info: CentralInfoProtocol,
51
+ event_bus_provider: EventBusProviderProtocol,
52
+ event_publisher: EventPublisherProtocol,
53
+ task_scheduler: TaskSchedulerProtocol,
54
+ paramset_description_provider: ParamsetDescriptionProviderProtocol,
55
+ parameter_visibility_provider: ParameterVisibilityProviderProtocol,
56
+ ) -> None:
57
+ """Initialize the data_point."""
58
+ PayloadMixin.__init__(self)
59
+ unique_id: Final = generate_unique_id(
60
+ config_provider=config_provider,
61
+ address=HUB_ADDRESS,
62
+ parameter=slugify(INBOX_SENSOR_NAME),
63
+ )
64
+ self._name_data: Final = get_hub_data_point_name_data(
65
+ channel=None, legacy_name=INBOX_SENSOR_NAME, central_name=central_info.name
66
+ )
67
+ super().__init__(
68
+ unique_id=unique_id,
69
+ central_info=central_info,
70
+ event_bus_provider=event_bus_provider,
71
+ event_publisher=event_publisher,
72
+ task_scheduler=task_scheduler,
73
+ paramset_description_provider=paramset_description_provider,
74
+ parameter_visibility_provider=parameter_visibility_provider,
75
+ )
76
+ self._state_uncertain: bool = True
77
+ self._devices: tuple[InboxDeviceData, ...] = ()
78
+ self._cached_device_count: int = 0
79
+
80
+ available: Final = DelegatedProperty[bool](path="_central_info.available", kind=Kind.STATE)
81
+ devices: Final = DelegatedProperty[tuple[InboxDeviceData, ...]](path="_devices", kind=Kind.STATE)
82
+ enabled_default: Final = DelegatedProperty[bool](path="_enabled_default")
83
+ full_name: Final = DelegatedProperty[str](path="_name_data.full_name")
84
+ name: Final = DelegatedProperty[str](path="_name_data.name", kind=Kind.CONFIG)
85
+ state_uncertain: Final = DelegatedProperty[bool](path="_state_uncertain")
86
+
87
+ @property
88
+ def channel(self) -> ChannelProtocol | None:
89
+ """Return the identified channel."""
90
+ return None
91
+
92
+ @property
93
+ def data_type(self) -> HubValueType | None:
94
+ """Return the data type of the system variable."""
95
+ return HubValueType.INTEGER
96
+
97
+ @property
98
+ def description(self) -> str | None:
99
+ """Return data point description."""
100
+ return None
101
+
102
+ @property
103
+ def legacy_name(self) -> str | None:
104
+ """Return the original name."""
105
+ return None
106
+
107
+ @config_property
108
+ def unit(self) -> str | None:
109
+ """Return the unit of the data_point."""
110
+ return None
111
+
112
+ @state_property
113
+ def value(self) -> int:
114
+ """Return the count of inbox devices."""
115
+ return len(self._devices)
116
+
117
+ def update_data(self, *, devices: tuple[InboxDeviceData, ...], write_at: datetime) -> None:
118
+ """Update the data point with new inbox devices."""
119
+ new_count = len(devices)
120
+ if self._cached_device_count != new_count or self._devices != devices:
121
+ self._cached_device_count = new_count
122
+ self._devices = devices
123
+ self._set_modified_at(modified_at=write_at)
124
+ else:
125
+ self._set_refreshed_at(refreshed_at=write_at)
126
+ self._state_uncertain = False
127
+ self.publish_data_point_updated_event()
128
+
129
+ def _get_path_data(self) -> PathData:
130
+ """Return the path data of the data_point."""
131
+ return HubPathData(name=slugify(INBOX_SENSOR_NAME))
132
+
133
+ def _get_signature(self) -> str:
134
+ """Return the signature of the data_point."""
135
+ return f"{self._category}/{self.name}"
@@ -0,0 +1,393 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """Module for install mode hub data points."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ from datetime import datetime, timedelta
9
+ import logging
10
+ from typing import Final, NamedTuple
11
+
12
+ from slugify import slugify
13
+
14
+ from aiohomematic.const import INIT_DATETIME, INSTALL_MODE_ADDRESS, DataPointCategory, HubValueType, InstallModeData
15
+ from aiohomematic.decorators import inspector
16
+ from aiohomematic.interfaces import (
17
+ CentralInfoProtocol,
18
+ ChannelLookupProtocol,
19
+ ChannelProtocol,
20
+ ConfigProviderProtocol,
21
+ EventBusProviderProtocol,
22
+ EventPublisherProtocol,
23
+ GenericHubDataPointProtocol,
24
+ GenericInstallModeDataPointProtocol,
25
+ ParameterVisibilityProviderProtocol,
26
+ ParamsetDescriptionProviderProtocol,
27
+ PrimaryClientProviderProtocol,
28
+ TaskSchedulerProtocol,
29
+ )
30
+ from aiohomematic.model.data_point import CallbackDataPoint
31
+ from aiohomematic.model.support import HubPathData, generate_unique_id, get_hub_data_point_name_data
32
+ from aiohomematic.property_decorators import DelegatedProperty, Kind, config_property, state_property
33
+ from aiohomematic.support import PayloadMixin
34
+
35
+ _LOGGER: Final = logging.getLogger(__name__)
36
+
37
+ _SYNC_INTERVAL: Final = 10 # Sync with backend every 10 seconds
38
+ _COUNTDOWN_UPDATE_INTERVAL: Final = 1 # Update countdown every second
39
+
40
+
41
+ class InstallModeDpType(NamedTuple):
42
+ """Tuple for install mode data points."""
43
+
44
+ button: InstallModeDpButton
45
+ sensor: InstallModeDpSensor
46
+
47
+
48
+ class _BaseInstallModeDataPoint(CallbackDataPoint, GenericHubDataPointProtocol, PayloadMixin):
49
+ """Base class for install mode data points."""
50
+
51
+ __slots__ = (
52
+ "_channel",
53
+ "_name_data",
54
+ "_primary_client_provider",
55
+ )
56
+
57
+ def __init__(
58
+ self,
59
+ *,
60
+ data: InstallModeData,
61
+ config_provider: ConfigProviderProtocol,
62
+ central_info: CentralInfoProtocol,
63
+ event_bus_provider: EventBusProviderProtocol,
64
+ event_publisher: EventPublisherProtocol,
65
+ task_scheduler: TaskSchedulerProtocol,
66
+ paramset_description_provider: ParamsetDescriptionProviderProtocol,
67
+ parameter_visibility_provider: ParameterVisibilityProviderProtocol,
68
+ channel_lookup: ChannelLookupProtocol,
69
+ primary_client_provider: PrimaryClientProviderProtocol,
70
+ ) -> None:
71
+ """Initialize the data_point."""
72
+ PayloadMixin.__init__(self)
73
+ unique_id: Final = generate_unique_id(
74
+ config_provider=config_provider,
75
+ address=INSTALL_MODE_ADDRESS,
76
+ parameter=slugify(data.name),
77
+ )
78
+ self._channel = channel_lookup.identify_channel(text=data.name)
79
+ self._name_data: Final = get_hub_data_point_name_data(
80
+ channel=self._channel, legacy_name=f"{INSTALL_MODE_ADDRESS}_{data.name}", central_name=central_info.name
81
+ )
82
+ super().__init__(
83
+ unique_id=unique_id,
84
+ central_info=central_info,
85
+ event_bus_provider=event_bus_provider,
86
+ event_publisher=event_publisher,
87
+ task_scheduler=task_scheduler,
88
+ paramset_description_provider=paramset_description_provider,
89
+ parameter_visibility_provider=parameter_visibility_provider,
90
+ )
91
+ self._primary_client_provider: Final = primary_client_provider
92
+
93
+ channel: Final = DelegatedProperty[ChannelProtocol | None](path="_channel")
94
+ full_name: Final = DelegatedProperty[str](path="_name_data.full_name")
95
+ name: Final = DelegatedProperty[str](path="_name_data.name", kind=Kind.CONFIG)
96
+
97
+ @property
98
+ def enabled_default(self) -> bool:
99
+ """Return if the data_point should be enabled."""
100
+ return True
101
+
102
+ @property
103
+ def legacy_name(self) -> str | None:
104
+ """Return the original name."""
105
+ return None
106
+
107
+ @property
108
+ def state_uncertain(self) -> bool:
109
+ """Return if the state is uncertain."""
110
+ return False
111
+
112
+ @config_property
113
+ def description(self) -> str | None:
114
+ """Return description."""
115
+ return None
116
+
117
+ @state_property
118
+ def available(self) -> bool:
119
+ """Return the availability of the device."""
120
+ if client := self._primary_client_provider.primary_client:
121
+ return client.capabilities.install_mode and self._central_info.available
122
+ return False
123
+
124
+ def _get_path_data(self) -> HubPathData:
125
+ """Return the path data of the data_point."""
126
+ return HubPathData(name=self._name_data.name)
127
+
128
+ def _get_signature(self) -> str:
129
+ """Return the signature of the data_point."""
130
+ return f"{self._category}/{self.name}"
131
+
132
+
133
+ class InstallModeDpSensor(GenericInstallModeDataPointProtocol, _BaseInstallModeDataPoint):
134
+ """Sensor showing remaining install mode time."""
135
+
136
+ __slots__ = (
137
+ "_countdown_end",
138
+ "_countdown_task",
139
+ "_sync_task",
140
+ "_task_lock",
141
+ )
142
+
143
+ _category = DataPointCategory.HUB_SENSOR
144
+
145
+ def __init__(
146
+ self,
147
+ *,
148
+ data: InstallModeData,
149
+ config_provider: ConfigProviderProtocol,
150
+ central_info: CentralInfoProtocol,
151
+ event_bus_provider: EventBusProviderProtocol,
152
+ event_publisher: EventPublisherProtocol,
153
+ task_scheduler: TaskSchedulerProtocol,
154
+ paramset_description_provider: ParamsetDescriptionProviderProtocol,
155
+ parameter_visibility_provider: ParameterVisibilityProviderProtocol,
156
+ channel_lookup: ChannelLookupProtocol,
157
+ primary_client_provider: PrimaryClientProviderProtocol,
158
+ ) -> None:
159
+ """Initialize the sensor."""
160
+ super().__init__(
161
+ config_provider=config_provider,
162
+ central_info=central_info,
163
+ event_bus_provider=event_bus_provider,
164
+ event_publisher=event_publisher,
165
+ task_scheduler=task_scheduler,
166
+ paramset_description_provider=paramset_description_provider,
167
+ parameter_visibility_provider=parameter_visibility_provider,
168
+ channel_lookup=channel_lookup,
169
+ primary_client_provider=primary_client_provider,
170
+ data=data,
171
+ )
172
+ self._countdown_end: datetime = INIT_DATETIME
173
+ self._countdown_task: asyncio.Task[None] | None = None
174
+ self._sync_task: asyncio.Task[None] | None = None
175
+ self._task_lock: Final = asyncio.Lock()
176
+
177
+ @property
178
+ def data_type(self) -> HubValueType | None:
179
+ """Return the data type of the system variable."""
180
+ return HubValueType.INTEGER
181
+
182
+ @property
183
+ def is_active(self) -> bool:
184
+ """Return if install mode is active."""
185
+ return self.value > 0
186
+
187
+ @config_property
188
+ def unit(self) -> str | None:
189
+ """Return the unit of the data_point."""
190
+ return None
191
+
192
+ @state_property
193
+ def value(self) -> int:
194
+ """Return remaining seconds."""
195
+ if self._countdown_end <= datetime.now():
196
+ return 0
197
+ return max(0, int((self._countdown_end - datetime.now()).total_seconds()))
198
+
199
+ def start_countdown(self, *, seconds: int) -> None:
200
+ """Start local countdown."""
201
+ self._countdown_end = datetime.now() + timedelta(seconds=seconds)
202
+ self._task_scheduler.create_task(
203
+ target=self._start_tasks_locked(),
204
+ name="install_mode_start_tasks",
205
+ )
206
+ self.publish_data_point_updated_event()
207
+
208
+ def stop_countdown(self) -> None:
209
+ """Stop countdown."""
210
+ self._countdown_end = INIT_DATETIME
211
+ self._task_scheduler.create_task(
212
+ target=self._stop_tasks_locked(),
213
+ name="install_mode_stop_tasks",
214
+ )
215
+ self.publish_data_point_updated_event()
216
+
217
+ def sync_from_backend(self, *, remaining_seconds: int) -> None:
218
+ """Sync countdown from backend value."""
219
+ if remaining_seconds <= 0:
220
+ self.stop_countdown()
221
+ else:
222
+ # Only resync if significant drift (>3 seconds)
223
+ if abs(self.value - remaining_seconds) > 3:
224
+ self._countdown_end = datetime.now() + timedelta(seconds=remaining_seconds)
225
+ self._task_scheduler.create_task(
226
+ target=self._ensure_tasks_running_locked(),
227
+ name="install_mode_ensure_tasks",
228
+ )
229
+ self.publish_data_point_updated_event()
230
+
231
+ async def _backend_sync_loop(self) -> None:
232
+ """Periodically sync with backend."""
233
+ try:
234
+ while self.is_active:
235
+ await asyncio.sleep(_SYNC_INTERVAL)
236
+ if client := self._primary_client_provider.primary_client:
237
+ if (backend_remaining := await client.get_install_mode()) == 0:
238
+ self.stop_countdown()
239
+ break
240
+ # Resync if significant drift
241
+ if abs(self.value - backend_remaining) > 3:
242
+ self._countdown_end = datetime.now() + timedelta(seconds=backend_remaining)
243
+ self.publish_data_point_updated_event()
244
+ except asyncio.CancelledError:
245
+ raise
246
+ except Exception:
247
+ _LOGGER.exception("INSTALL_MODE: Backend sync loop failed") # i18n-log: ignore
248
+ self.stop_countdown()
249
+
250
+ async def _countdown_update_loop(self) -> None:
251
+ """Update countdown value every second."""
252
+ try:
253
+ while self.is_active:
254
+ await asyncio.sleep(_COUNTDOWN_UPDATE_INTERVAL)
255
+ if self.value <= 0:
256
+ self.stop_countdown()
257
+ break
258
+ self.publish_data_point_updated_event()
259
+ except asyncio.CancelledError:
260
+ raise
261
+ except Exception:
262
+ _LOGGER.exception("INSTALL_MODE: Countdown update loop failed") # i18n-log: ignore
263
+ self.stop_countdown()
264
+
265
+ async def _ensure_tasks_running_locked(self) -> None:
266
+ """Ensure tasks are running with lock protection."""
267
+ async with self._task_lock:
268
+ if not self._countdown_task or self._countdown_task.done():
269
+ self._countdown_task = self._task_scheduler.create_task(
270
+ target=self._countdown_update_loop(),
271
+ name="install_mode_countdown",
272
+ )
273
+ if not self._sync_task or self._sync_task.done():
274
+ self._sync_task = self._task_scheduler.create_task(
275
+ target=self._backend_sync_loop(),
276
+ name="install_mode_sync",
277
+ )
278
+
279
+ async def _start_tasks_locked(self) -> None:
280
+ """Start all tasks with lock protection."""
281
+ async with self._task_lock:
282
+ self._stop_countdown_task_unlocked()
283
+ self._stop_sync_task_unlocked()
284
+ self._countdown_task = self._task_scheduler.create_task(
285
+ target=self._countdown_update_loop(),
286
+ name="install_mode_countdown",
287
+ )
288
+ self._sync_task = self._task_scheduler.create_task(
289
+ target=self._backend_sync_loop(),
290
+ name="install_mode_sync",
291
+ )
292
+
293
+ def _stop_countdown_task_unlocked(self) -> None:
294
+ """Stop countdown task without lock. Must be called with _task_lock held."""
295
+ if self._countdown_task and not self._countdown_task.done():
296
+ self._countdown_task.cancel()
297
+ self._countdown_task = None
298
+
299
+ def _stop_sync_task_unlocked(self) -> None:
300
+ """Stop sync task without lock. Must be called with _task_lock held."""
301
+ if self._sync_task and not self._sync_task.done():
302
+ self._sync_task.cancel()
303
+ self._sync_task = None
304
+
305
+ async def _stop_tasks_locked(self) -> None:
306
+ """Stop all tasks with lock protection."""
307
+ async with self._task_lock:
308
+ self._stop_countdown_task_unlocked()
309
+ self._stop_sync_task_unlocked()
310
+
311
+
312
+ class InstallModeDpButton(_BaseInstallModeDataPoint):
313
+ """Button to activate/deactivate install mode."""
314
+
315
+ __slots__ = ("_sensor",)
316
+
317
+ _category = DataPointCategory.HUB_BUTTON
318
+
319
+ def __init__(
320
+ self,
321
+ *,
322
+ data: InstallModeData,
323
+ sensor: InstallModeDpSensor,
324
+ config_provider: ConfigProviderProtocol,
325
+ central_info: CentralInfoProtocol,
326
+ event_bus_provider: EventBusProviderProtocol,
327
+ event_publisher: EventPublisherProtocol,
328
+ task_scheduler: TaskSchedulerProtocol,
329
+ paramset_description_provider: ParamsetDescriptionProviderProtocol,
330
+ parameter_visibility_provider: ParameterVisibilityProviderProtocol,
331
+ channel_lookup: ChannelLookupProtocol,
332
+ primary_client_provider: PrimaryClientProviderProtocol,
333
+ ) -> None:
334
+ """Initialize the button."""
335
+ super().__init__(
336
+ data=data,
337
+ config_provider=config_provider,
338
+ central_info=central_info,
339
+ event_bus_provider=event_bus_provider,
340
+ event_publisher=event_publisher,
341
+ task_scheduler=task_scheduler,
342
+ paramset_description_provider=paramset_description_provider,
343
+ parameter_visibility_provider=parameter_visibility_provider,
344
+ channel_lookup=channel_lookup,
345
+ primary_client_provider=primary_client_provider,
346
+ )
347
+ self._sensor: Final = sensor
348
+
349
+ sensor: Final = DelegatedProperty[GenericInstallModeDataPointProtocol](path="_sensor")
350
+
351
+ @inspector
352
+ async def activate(
353
+ self,
354
+ *,
355
+ time: int = 60,
356
+ device_address: str | None = None,
357
+ ) -> bool:
358
+ """
359
+ Activate install mode.
360
+
361
+ Args:
362
+ time: Duration in seconds (default 60).
363
+ device_address: Optional device address to limit pairing.
364
+
365
+ Returns:
366
+ True if successful.
367
+
368
+ """
369
+ if (client := self._primary_client_provider.primary_client) and await client.set_install_mode(
370
+ on=True, time=time, device_address=device_address
371
+ ):
372
+ self._sensor.start_countdown(seconds=time)
373
+ return True
374
+ return False
375
+
376
+ @inspector
377
+ async def deactivate(self) -> bool:
378
+ """
379
+ Deactivate install mode.
380
+
381
+ Returns:
382
+ True if successful.
383
+
384
+ """
385
+ if (client := self._primary_client_provider.primary_client) and await client.set_install_mode(on=False):
386
+ self._sensor.stop_countdown()
387
+ return True
388
+ return False
389
+
390
+ @inspector
391
+ async def press(self) -> None:
392
+ """Activate install mode with default settings."""
393
+ await self.activate()