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,1827 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """
4
+ Module for handling week profiles.
5
+
6
+ This module provides scheduling functionality for HomeMatic devices, supporting both
7
+ climate devices (thermostats) and non-climate devices (switches, lights, covers, valves).
8
+
9
+ SCHEDULE SYSTEM OVERVIEW
10
+ ========================
11
+
12
+ The schedule system manages weekly time-based automation for HomeMatic devices. It handles
13
+ conversion between CCU raw paramset format and structured Python dictionaries, providing
14
+ validation, filtering, and normalization of schedule data.
15
+
16
+ Two main implementations:
17
+ - ClimeateWeekProfile: Manages climate device schedules (thermostats)
18
+ - DefaultWeekProfile: Manages non-climate device schedules (switches, lights, covers, valves)
19
+
20
+
21
+ CLIMATE SCHEDULE DATA STRUCTURES
22
+ =================================
23
+
24
+ Climate schedules use a hierarchical structure with three levels:
25
+
26
+ 1. ClimateScheduleDict (Complete Schedule)
27
+ Structure: dict[ScheduleProfile, ClimateProfileSchedule]
28
+
29
+ Contains all profiles (P1-P6) for a thermostat device.
30
+
31
+ Example:
32
+ {
33
+ ScheduleProfile.P1: {
34
+ "MONDAY": {1: {...}, 2: {...}, ...},
35
+ "TUESDAY": {1: {...}, 2: {...}, ...},
36
+ ...
37
+ },
38
+ ScheduleProfile.P2: {...},
39
+ ...
40
+ }
41
+
42
+ 2. ClimateProfileSchedule (Single Profile)
43
+ Structure: dict[WeekdayStr, ClimateWeekdaySchedule]
44
+
45
+ Contains all weekdays for a single profile (e.g., P1).
46
+
47
+ Example:
48
+ {
49
+ "MONDAY": {
50
+ 1: {"endtime": "06:00", "temperature": 18.0},
51
+ 2: {"endtime": "22:00", "temperature": 21.0},
52
+ 3: {"endtime": "24:00", "temperature": 18.0},
53
+ ...
54
+ },
55
+ "TUESDAY": {...},
56
+ ...
57
+ }
58
+
59
+ 3. ClimateWeekdaySchedule (Single Weekday)
60
+ Structure: dict[int, ScheduleSlot]
61
+
62
+ Contains 13 time slots for a single weekday. Each slot is a ScheduleSlot TypedDict with
63
+ "endtime" and "temperature" keys. Slots define periods where the thermostat maintains
64
+ a specific temperature until the endtime is reached.
65
+
66
+ ScheduleSlot TypedDict:
67
+ endtime: str # End time in "HH:MM" format
68
+ temperature: float # Target temperature in Celsius
69
+
70
+ Example:
71
+ {
72
+ 1: {"endtime": "06:00", "temperature": 18.0},
73
+ 2: {"endtime": "08:00", "temperature": 21.0},
74
+ 3: {"endtime": "17:00", "temperature": 18.0},
75
+ 4: {"endtime": "22:00", "temperature": 21.0},
76
+ 5: {"endtime": "24:00", "temperature": 18.0},
77
+ 6-13: {"endtime": "24:00", "temperature": 18.0}
78
+ }
79
+
80
+ Note: Always contains exactly 13 slots. Unused slots are filled with 24:00 entries.
81
+
82
+
83
+ RAW SCHEDULE FORMAT
84
+ ===================
85
+
86
+ CCU devices store schedules in a flat paramset format:
87
+
88
+ Example (Climate):
89
+ {
90
+ "P1_TEMPERATURE_MONDAY_1": 18.0,
91
+ "P1_ENDTIME_MONDAY_1": 360, # 06:00 in minutes
92
+ "P1_TEMPERATURE_MONDAY_2": 21.0,
93
+ "P1_ENDTIME_MONDAY_2": 480, # 08:00 in minutes
94
+ ...
95
+ }
96
+
97
+ Example (Switch):
98
+ {
99
+ "01_WP_WEEKDAY": 127, # Bitwise: all days (0b1111111)
100
+ "01_WP_LEVEL": 1, # On/Off state
101
+ "01_WP_FIXED_HOUR": 7,
102
+ "01_WP_FIXED_MINUTE": 30,
103
+ ...
104
+ }
105
+
106
+
107
+ SIMPLE SCHEDULE FORMAT
108
+ ======================
109
+
110
+ A simplified format for easy user input, focusing on temperature periods without
111
+ redundant 24:00 slots. The base temperature is automatically identified or can be
112
+ specified as part of the data structure. Uses TypedDict-based structures with
113
+ lowercase string keys for full JSON serialization support.
114
+
115
+ SimpleWeekdaySchedule (TypedDict):
116
+ A dictionary containing:
117
+ - "base_temperature" (float): The temperature used for periods not explicitly defined
118
+ - "periods" (list): Non-base temperature periods with starttime, endtime, temperature
119
+
120
+ Example:
121
+ {
122
+ "base_temperature": 18.0,
123
+ "periods": [
124
+ {
125
+ "starttime": "06:00",
126
+ "endtime": "08:00",
127
+ "temperature": 21.0
128
+ },
129
+ {
130
+ "starttime": "17:00",
131
+ "endtime": "22:00",
132
+ "temperature": 21.0
133
+ }
134
+ ]
135
+ }
136
+
137
+ SimpleProfileSchedule:
138
+ Structure: dict[WeekdayStr, SimpleWeekdaySchedule]
139
+
140
+ Maps weekday names to their simple weekday data (base temp + periods).
141
+
142
+ SimpleScheduleDict:
143
+ Structure: dict[ScheduleProfile, SimpleProfileSchedule]
144
+
145
+ Maps profiles (P1-P6) to their simple profile data.
146
+
147
+ The system automatically:
148
+ - Identifies base_temperature when converting from full format (using identify_base_temperature())
149
+ - Fills gaps with base_temperature when converting to full format
150
+ - Converts to full 13-slot format
151
+ - Sorts by time
152
+ - Validates ranges
153
+
154
+
155
+ SCHEDULE SERVICES
156
+ =================
157
+
158
+ Core Operations:
159
+ ----------------
160
+
161
+ Full Format Methods:
162
+ ~~~~~~~~~~~~~~~~~~~~
163
+
164
+ get_schedule(*, force_load: bool = False) -> ClimateScheduleDict
165
+ Retrieves complete schedule from cache or device.
166
+ Returns filtered data (redundant 24:00 slots removed).
167
+
168
+ get_profile(*, profile: ScheduleProfile, force_load: bool = False) -> ClimateProfileSchedule
169
+ Retrieves single profile (e.g., P1) from cache or device.
170
+ Returns filtered data for the specified profile.
171
+
172
+ get_weekday(*, profile: ScheduleProfile, weekday: WeekdayStr, force_load: bool = False) -> ClimateWeekdaySchedule
173
+ Retrieves single weekday schedule from a profile.
174
+ Returns filtered data for the specified weekday.
175
+
176
+ set_schedule(*, schedule_data: ClimateScheduleDict) -> None
177
+ Persists complete schedule to device.
178
+ Updates cache and publishes change events.
179
+
180
+ set_profile(*, profile: ScheduleProfile, profile_data: ClimateProfileSchedule) -> None
181
+ Persists single profile to device.
182
+ Validates, updates cache, and publishes change events.
183
+
184
+ set_weekday(*, profile: ScheduleProfile, weekday: WeekdayStr, weekday_data: ClimateWeekdaySchedule) -> None
185
+ Persists single weekday schedule to device.
186
+ Normalizes to 13 slots, validates, updates cache.
187
+
188
+ Simple Format Methods:
189
+ ~~~~~~~~~~~~~~~~~~~~~~
190
+
191
+ get_simple_schedule(*, force_load: bool = False) -> SimpleScheduleDict
192
+ Retrieves complete schedule in simplified format from cache or device.
193
+ Automatically identifies base_temperature for each weekday.
194
+ Returns dict[ScheduleProfile, dict[WeekdayStr, SimpleWeekdaySchedule]].
195
+
196
+ get_simple_profile(*, profile: ScheduleProfile, force_load: bool = False) -> SimpleProfileSchedule
197
+ Retrieves single profile in simplified format from cache or device.
198
+ Automatically identifies base_temperature for each weekday.
199
+ Returns dict[WeekdayStr, SimpleWeekdaySchedule] for the specified profile.
200
+
201
+ get_simple_weekday(*, profile: ScheduleProfile, weekday: WeekdayStr, force_load: bool = False) -> SimpleWeekdaySchedule
202
+ Retrieves single weekday in simplified format from cache or device.
203
+ Automatically identifies base_temperature.
204
+ Returns SimpleWeekdaySchedule with base_temperature and periods list.
205
+
206
+ set_simple_schedule(*, simple_schedule_data: SimpleScheduleDict) -> None
207
+ Persists complete schedule using simplified format to device.
208
+ Converts simple format to full 13-slot format automatically.
209
+ Expects dict[ScheduleProfile, dict[WeekdayStr, SimpleWeekdaySchedule]].
210
+
211
+ set_simple_profile(*, profile: ScheduleProfile, simple_profile_data: SimpleProfileSchedule) -> None
212
+ Persists single profile using simplified format to device.
213
+ Converts simple format to full 13-slot format automatically.
214
+ Expects dict[WeekdayStr, SimpleWeekdaySchedule].
215
+
216
+ set_simple_weekday(*, profile: ScheduleProfile, weekday: WeekdayStr, simple_weekday_data: SimpleWeekdaySchedule) -> None
217
+ Persists single weekday using simplified format to device.
218
+ Converts simple format to full 13-slot format automatically.
219
+ Expects SimpleWeekdaySchedule with base_temperature and periods.
220
+
221
+ Utility Methods:
222
+ ~~~~~~~~~~~~~~~~
223
+
224
+ copy_schedule(*, target_climate_data_point: BaseCustomDpClimate | None = None) -> None
225
+ Copies entire schedule from this device to another.
226
+
227
+ copy_profile(*, source_profile: ScheduleProfile, target_profile: ScheduleProfile, target_climate_data_point: BaseCustomDpClimate | None = None) -> None
228
+ Copies single profile to another profile/device.
229
+
230
+
231
+ DATA PROCESSING PIPELINE
232
+ =========================
233
+
234
+ Filtering (Output - Removes Redundancy):
235
+ -----------------------------------------
236
+ Applied when reading schedules to present clean data to users.
237
+
238
+ _filter_schedule_entries(schedule_data) -> ClimateScheduleDict
239
+ Filters all profiles in a complete schedule.
240
+
241
+ _filter_profile_entries(profile_data) -> ClimateProfileSchedule
242
+ Filters all weekdays in a profile.
243
+
244
+ _filter_weekday_entries(weekday_data) -> ClimateWeekdaySchedule
245
+ Filters redundant 24:00 slots from a weekday schedule:
246
+ - Processes slots in slot-number order
247
+ - Keeps all slots up to and including the first 24:00
248
+ - Stops at the first occurrence of 24:00 (ignores all subsequent slots)
249
+ - Renumbers remaining slots sequentially (1, 2, 3, ...)
250
+
251
+ Example:
252
+ Input: {1: {ENDTIME: "06:00"}, 2: {ENDTIME: "12:00"}, 3: {ENDTIME: "24:00"}, 4: {ENDTIME: "18:00"}, ..., 13: {ENDTIME: "24:00"}}
253
+ Output: {1: {ENDTIME: "06:00"}, 2: {ENDTIME: "12:00"}, 3: {ENDTIME: "24:00"}}
254
+
255
+
256
+ Normalization (Input - Ensures Valid Format):
257
+ ----------------------------------------------
258
+ Applied when setting schedules to ensure data meets device requirements.
259
+
260
+ _normalize_weekday_data(weekday_data) -> ClimateWeekdaySchedule
261
+ Normalizes weekday schedule data:
262
+ - Converts string keys to integers
263
+ - Sorts slots chronologically by ENDTIME
264
+ - Renumbers slots sequentially (1-N)
265
+ - Fills missing slots (N+1 to 13) with 24:00 entries
266
+ - Always returns exactly 13 slots
267
+
268
+ Example:
269
+ Input: {"2": {ENDTIME: "12:00"}, "1": {ENDTIME: "06:00"}}
270
+ Output: {
271
+ 1: {ENDTIME: "06:00", TEMPERATURE: 20.0},
272
+ 2: {ENDTIME: "12:00", TEMPERATURE: 21.0},
273
+ 3-13: {ENDTIME: "24:00", TEMPERATURE: 21.0} # Filled automatically
274
+ }
275
+
276
+
277
+ TYPICAL WORKFLOW EXAMPLES
278
+ ==========================
279
+
280
+ Reading a Schedule:
281
+ -------------------
282
+ 1. User calls get_weekday(profile=P1, weekday="MONDAY")
283
+ 2. System retrieves from cache or device (13 slots)
284
+ 3. _filter_weekday_entries removes redundant 24:00 slots
285
+ 4. User receives clean data (e.g., 3-5 meaningful slots)
286
+
287
+ Setting a Schedule:
288
+ -------------------
289
+ 1. User provides schedule data (may be incomplete, unsorted)
290
+ 2. System calls _normalize_weekday_data to:
291
+ - Sort by time
292
+ - Fill to exactly 13 slots
293
+ 3. System validates (temperature ranges, time ranges, sequence)
294
+ 4. System persists to device
295
+ 5. Cache is updated, events are published
296
+
297
+ Using Simple Format:
298
+ --------------------
299
+ 1. User calls set_simple_weekday with:
300
+ - profile: ScheduleProfile.P1
301
+ - weekday: WeekdayStr.MONDAY
302
+ - simple_weekday_data: (18.0, [{STARTTIME: "07:00", ENDTIME: "22:00", TEMPERATURE: 21.0}])
303
+ ^^^^^ base_temperature is part of the tuple
304
+ 2. System extracts base_temperature (18.0) and periods from tuple
305
+ 3. System converts to full format:
306
+ - Slot 1: ENDTIME: "07:00", TEMP: 18.0 (base_temperature before start)
307
+ - Slot 2: ENDTIME: "22:00", TEMP: 21.0 (user's period)
308
+ - Slots 3-13: ENDTIME: "24:00", TEMP: 18.0 (base_temperature after end)
309
+ 4. System validates and persists
310
+
311
+ Reading Simple Format:
312
+ ----------------------
313
+ 1. User calls get_simple_weekday(profile=P1, weekday="MONDAY")
314
+ 2. System retrieves full schedule from cache (13 slots)
315
+ 3. System identifies base_temperature using identify_base_temperature()
316
+ - Analyzes time durations for each temperature
317
+ - Returns temperature used for most minutes of the day
318
+ 4. System filters out base_temperature periods and returns:
319
+ (18.0, [{STARTTIME: "07:00", ENDTIME: "22:00", TEMPERATURE: 21.0}])
320
+ ^^^^^ identified base_temperature + list of non-base periods
321
+
322
+ DATA FLOW SUMMARY
323
+ =================
324
+
325
+ Device → Python (Reading):
326
+ Raw Paramset → convert_raw_to_dict_schedule() → Cache (13 slots) →
327
+ _filter_*_entries() → User (clean, minimal slots)
328
+
329
+ Python → Device (Writing):
330
+ User Data → _normalize_weekday_data() → Full 13 slots → Validation →
331
+ convert_dict_to_raw_schedule() → Raw Paramset → Device
332
+
333
+ Simple → Full Format (Writing):
334
+ Simple Tuple (base_temp, list) → _validate_and_convert_simple_to_weekday() →
335
+ Full 13 slots → Normal writing flow
336
+
337
+ Full → Simple Format (Reading):
338
+ Full 13 slots → identify_base_temperature() (analyzes time durations) →
339
+ _validate_and_convert_weekday_to_simple() →
340
+ Simple Tuple (base_temp, non-base temperature periods only)
341
+
342
+ """
343
+
344
+ from __future__ import annotations
345
+
346
+ from abc import ABC, abstractmethod
347
+ from enum import IntEnum
348
+ import logging
349
+ from typing import TYPE_CHECKING, Any, Final, cast
350
+
351
+ from aiohomematic import i18n
352
+ from aiohomematic.const import (
353
+ BIDCOS_DEVICE_CHANNEL_DUMMY,
354
+ CLIMATE_MAX_SCHEDULER_TIME,
355
+ CLIMATE_MIN_SCHEDULER_TIME,
356
+ CLIMATE_RELEVANT_SLOT_TYPES,
357
+ CLIMATE_SCHEDULE_SLOT_IN_RANGE,
358
+ CLIMATE_SCHEDULE_SLOT_RANGE,
359
+ CLIMATE_SCHEDULE_TIME_RANGE,
360
+ DEFAULT_CLIMATE_FILL_TEMPERATURE,
361
+ DEFAULT_SCHEDULE_DICT,
362
+ DEFAULT_SCHEDULE_GROUP,
363
+ RAW_SCHEDULE_DICT,
364
+ SCHEDULE_PATTERN,
365
+ SCHEDULER_PROFILE_PATTERN,
366
+ SCHEDULER_TIME_PATTERN,
367
+ AstroType,
368
+ ClimateProfileSchedule,
369
+ ClimateScheduleDict,
370
+ ClimateWeekdaySchedule,
371
+ DataPointCategory,
372
+ ParamsetKey,
373
+ ScheduleActorChannel,
374
+ ScheduleCondition,
375
+ ScheduleField,
376
+ ScheduleProfile,
377
+ SimpleProfileSchedule,
378
+ SimpleScheduleDict,
379
+ SimpleSchedulePeriod,
380
+ SimpleWeekdaySchedule,
381
+ TimeBase,
382
+ WeekdayInt,
383
+ WeekdayStr,
384
+ )
385
+ from aiohomematic.decorators import inspector
386
+ from aiohomematic.exceptions import ClientException, ValidationException
387
+ from aiohomematic.interfaces import CustomDataPointProtocol, WeekProfileProtocol
388
+
389
+ if TYPE_CHECKING:
390
+ from aiohomematic.model.custom import BaseCustomDpClimate
391
+
392
+ _LOGGER = logging.getLogger(__name__)
393
+
394
+
395
+ class WeekProfile[SCHEDULE_DICT_T: dict[Any, Any]](ABC, WeekProfileProtocol[SCHEDULE_DICT_T]):
396
+ """Handle the device week profile."""
397
+
398
+ __slots__ = (
399
+ "_client",
400
+ "_data_point",
401
+ "_device",
402
+ "_schedule_cache",
403
+ "_schedule_channel_no",
404
+ )
405
+
406
+ def __init__(self, *, data_point: CustomDataPointProtocol) -> None:
407
+ """Initialize the device schedule."""
408
+ self._data_point = data_point
409
+ self._device: Final = data_point.device
410
+ self._client: Final = data_point.device.client
411
+ self._schedule_channel_no: Final[int | None] = self._data_point.device_config.schedule_channel_no
412
+ self._schedule_cache: SCHEDULE_DICT_T = cast(SCHEDULE_DICT_T, {})
413
+
414
+ @staticmethod
415
+ @abstractmethod
416
+ def convert_dict_to_raw_schedule(*, schedule_data: SCHEDULE_DICT_T) -> RAW_SCHEDULE_DICT:
417
+ """Convert dictionary to raw schedule."""
418
+
419
+ @staticmethod
420
+ @abstractmethod
421
+ def convert_raw_to_dict_schedule(*, raw_schedule: RAW_SCHEDULE_DICT) -> SCHEDULE_DICT_T:
422
+ """Convert raw schedule to dictionary format."""
423
+
424
+ @property
425
+ def has_schedule(self) -> bool:
426
+ """Flag if climate supports schedule."""
427
+ return self.schedule_channel_address is not None
428
+
429
+ @property
430
+ def schedule(self) -> SCHEDULE_DICT_T:
431
+ """Return the schedule cache."""
432
+ return self._schedule_cache
433
+
434
+ @property
435
+ def schedule_channel_address(self) -> str | None:
436
+ """Return schedule channel address."""
437
+ if self._schedule_channel_no == BIDCOS_DEVICE_CHANNEL_DUMMY:
438
+ return self._device.address
439
+ if self._schedule_channel_no is not None:
440
+ return f"{self._device.address}:{self._schedule_channel_no}"
441
+ if (
442
+ self._device.default_schedule_channel
443
+ and (dsca := self._device.default_schedule_channel.address) is not None
444
+ ):
445
+ return dsca
446
+ return None
447
+
448
+ @abstractmethod
449
+ async def get_schedule(self, *, force_load: bool = False) -> SCHEDULE_DICT_T:
450
+ """Return the schedule dictionary."""
451
+
452
+ @abstractmethod
453
+ async def reload_and_cache_schedule(self, *, force: bool = False) -> None:
454
+ """Reload schedule entries and update cache."""
455
+
456
+ @abstractmethod
457
+ async def set_schedule(self, *, schedule_data: SCHEDULE_DICT_T) -> None:
458
+ """Persist the provided schedule dictionary."""
459
+
460
+ def _filter_schedule_entries(self, *, schedule_data: SCHEDULE_DICT_T) -> SCHEDULE_DICT_T:
461
+ """Filter schedule entries by removing invalid/not relevant entries."""
462
+ return schedule_data
463
+
464
+ def _validate_and_get_schedule_channel_address(self) -> str:
465
+ """
466
+ Validate that schedule is supported and return the channel address.
467
+
468
+ Returns:
469
+ The schedule channel address
470
+
471
+ Raises:
472
+ ValidationException: If schedule is not supported
473
+
474
+ """
475
+ if (sca := self.schedule_channel_address) is None:
476
+ raise ValidationException(
477
+ i18n.tr(
478
+ key="exception.model.week_profile.schedule.unsupported",
479
+ address=self._device.name,
480
+ )
481
+ )
482
+ return sca
483
+
484
+
485
+ class DefaultWeekProfile(WeekProfile[DEFAULT_SCHEDULE_DICT]):
486
+ """
487
+ Handle device week profiles for switches, lights, covers, and valves.
488
+
489
+ This class manages the weekly scheduling functionality for non-climate devices,
490
+ converting between CCU raw paramset format and structured Python dictionaries.
491
+ """
492
+
493
+ @staticmethod
494
+ def _convert_schedule_entries(*, values: RAW_SCHEDULE_DICT) -> RAW_SCHEDULE_DICT:
495
+ """
496
+ Extract only week profile (WP) entries from a raw paramset dictionary.
497
+
498
+ Filters paramset values to include only keys matching the pattern XX_WP_FIELDNAME.
499
+ """
500
+ schedule: RAW_SCHEDULE_DICT = {}
501
+ for key, value in values.items():
502
+ if not SCHEDULE_PATTERN.match(key):
503
+ continue
504
+ # The CCU reports ints/floats; cast to float for completeness
505
+ if isinstance(value, (int, float)):
506
+ schedule[key] = float(value) if isinstance(value, float) else value
507
+ return schedule
508
+
509
+ @staticmethod
510
+ def convert_dict_to_raw_schedule(*, schedule_data: DEFAULT_SCHEDULE_DICT) -> RAW_SCHEDULE_DICT:
511
+ """
512
+ Convert structured dictionary to raw paramset schedule.
513
+
514
+ Args:
515
+ schedule_data: Structured schedule dictionary
516
+
517
+ Returns:
518
+ Raw schedule for CCU
519
+
520
+ Example:
521
+ Input: {1: {SwitchScheduleField.WEEKDAY: [Weekday.SUNDAY, ...], ...}}
522
+ Output: {"01_WP_WEEKDAY": 127, "01_WP_LEVEL": 1, ...}
523
+
524
+ """
525
+ raw_schedule: RAW_SCHEDULE_DICT = {}
526
+
527
+ for group_no, group_data in schedule_data.items():
528
+ for field, value in group_data.items():
529
+ # Build parameter name: "01_WP_WEEKDAY"
530
+ key = f"{group_no:02d}_WP_{field.value}"
531
+
532
+ # Convert value based on field type
533
+ if field in (
534
+ ScheduleField.ASTRO_TYPE,
535
+ ScheduleField.CONDITION,
536
+ ScheduleField.DURATION_BASE,
537
+ ScheduleField.RAMP_TIME_BASE,
538
+ ):
539
+ raw_schedule[key] = int(value.value)
540
+ elif field in (ScheduleField.WEEKDAY, ScheduleField.TARGET_CHANNELS):
541
+ raw_schedule[key] = _list_to_bitwise(items=value)
542
+ elif field == ScheduleField.LEVEL:
543
+ raw_schedule[key] = int(value.value) if isinstance(value, IntEnum) else float(value)
544
+ elif field == ScheduleField.LEVEL_2:
545
+ raw_schedule[key] = float(value)
546
+ else:
547
+ # ASTRO_OFFSET, DURATION_FACTOR, FIXED_HOUR, FIXED_MINUTE, RAMP_TIME_FACTOR
548
+ raw_schedule[key] = int(value)
549
+
550
+ return raw_schedule
551
+
552
+ @staticmethod
553
+ def convert_raw_to_dict_schedule(*, raw_schedule: RAW_SCHEDULE_DICT) -> DEFAULT_SCHEDULE_DICT:
554
+ """
555
+ Convert raw paramset schedule to structured dictionary.
556
+
557
+ Args:
558
+ raw_schedule: Raw schedule from CCU (e.g., {"01_WP_WEEKDAY": 127, ...})
559
+
560
+ Returns:
561
+ Structured dictionary grouped by schedule number
562
+
563
+ Example:
564
+ Input: {"01_WP_WEEKDAY": 127, "01_WP_LEVEL": 1, ...}
565
+ Output: {1: {SwitchScheduleField.WEEKDAY: [Weekday.SUNDAY, ...], ...}}
566
+
567
+ """
568
+ schedule_data: DEFAULT_SCHEDULE_DICT = {}
569
+
570
+ for key, value in raw_schedule.items():
571
+ # Expected format: "01_WP_WEEKDAY"
572
+ parts = key.split("_", 2)
573
+ if len(parts) != 3 or parts[1] != "WP":
574
+ continue
575
+
576
+ try:
577
+ group_no = int(parts[0])
578
+ field_name = parts[2]
579
+ field = ScheduleField[field_name]
580
+ except (ValueError, KeyError):
581
+ # Skip invalid entries
582
+ continue
583
+
584
+ if group_no not in schedule_data:
585
+ schedule_data[group_no] = {}
586
+
587
+ # Convert value based on field type
588
+ int_value = int(value)
589
+
590
+ if field == ScheduleField.ASTRO_TYPE:
591
+ try:
592
+ schedule_data[group_no][field] = AstroType(int_value)
593
+ except ValueError:
594
+ # Unknown astro type - store as raw int for forward compatibility
595
+ schedule_data[group_no][field] = int_value
596
+ elif field == ScheduleField.CONDITION:
597
+ try:
598
+ schedule_data[group_no][field] = ScheduleCondition(int_value)
599
+ except ValueError:
600
+ # Unknown condition - store as raw int for forward compatibility
601
+ schedule_data[group_no][field] = int_value
602
+ elif field in (ScheduleField.DURATION_BASE, ScheduleField.RAMP_TIME_BASE):
603
+ try:
604
+ schedule_data[group_no][field] = TimeBase(int_value)
605
+ except ValueError:
606
+ # Unknown time base - store as raw int for forward compatibility
607
+ schedule_data[group_no][field] = int_value
608
+ elif field == ScheduleField.LEVEL:
609
+ schedule_data[group_no][field] = int_value if isinstance(value, int) else float(value)
610
+ elif field == ScheduleField.LEVEL_2:
611
+ schedule_data[group_no][field] = float(value)
612
+ elif field == ScheduleField.WEEKDAY:
613
+ schedule_data[group_no][field] = _bitwise_to_list(value=int_value, enum_class=WeekdayInt)
614
+ elif field == ScheduleField.TARGET_CHANNELS:
615
+ schedule_data[group_no][field] = _bitwise_to_list(value=int_value, enum_class=ScheduleActorChannel)
616
+ else:
617
+ # ASTRO_OFFSET, DURATION_FACTOR, FIXED_HOUR, FIXED_MINUTE, RAMP_TIME_FACTOR
618
+ schedule_data[group_no][field] = int_value
619
+
620
+ # Return all schedule groups, even if incomplete
621
+ # Filtering can be done by callers using is_schedule_active() if needed
622
+ return schedule_data
623
+
624
+ def empty_schedule_group(self) -> DEFAULT_SCHEDULE_GROUP:
625
+ """Return an empty schedule dictionary."""
626
+ if not self.has_schedule:
627
+ return create_empty_schedule_group(category=self._data_point.category)
628
+ return {}
629
+
630
+ @inspector
631
+ async def get_schedule(self, *, force_load: bool = False) -> DEFAULT_SCHEDULE_DICT:
632
+ """Return the raw schedule dictionary."""
633
+ if not self.has_schedule:
634
+ raise ValidationException(
635
+ i18n.tr(
636
+ key="exception.model.week_profile.schedule.unsupported",
637
+ address=self._device.name,
638
+ )
639
+ )
640
+ await self.reload_and_cache_schedule(force=force_load)
641
+ return self._schedule_cache
642
+
643
+ async def reload_and_cache_schedule(self, *, force: bool = False) -> None:
644
+ """Reload schedule entries and update cache."""
645
+ if not force and not self.has_schedule:
646
+ return
647
+
648
+ try:
649
+ new_raw_schedule = await self._get_raw_schedule()
650
+ except ValidationException:
651
+ return
652
+ old_schedule = self._schedule_cache
653
+ new_schedule_data = self.convert_raw_to_dict_schedule(raw_schedule=new_raw_schedule)
654
+ self._schedule_cache = {
655
+ no: group_data for no, group_data in new_schedule_data.items() if is_schedule_active(group_data=group_data)
656
+ }
657
+ if old_schedule != self._schedule_cache:
658
+ self._data_point.publish_data_point_updated_event()
659
+
660
+ @inspector
661
+ async def set_schedule(self, *, schedule_data: DEFAULT_SCHEDULE_DICT) -> None:
662
+ """Persist the provided raw schedule dictionary."""
663
+ sca = self._validate_and_get_schedule_channel_address()
664
+
665
+ old_schedule = self._schedule_cache
666
+ self._schedule_cache.update(schedule_data)
667
+ if old_schedule != self._schedule_cache:
668
+ self._data_point.publish_data_point_updated_event()
669
+
670
+ await self._client.put_paramset(
671
+ channel_address=sca,
672
+ paramset_key_or_link_address=ParamsetKey.MASTER,
673
+ values=self._convert_schedule_entries(
674
+ values=self.convert_dict_to_raw_schedule(schedule_data=schedule_data)
675
+ ),
676
+ )
677
+
678
+ async def _get_raw_schedule(self) -> RAW_SCHEDULE_DICT:
679
+ """Return the raw schedule dictionary filtered to WP entries."""
680
+ try:
681
+ sca = self._validate_and_get_schedule_channel_address()
682
+ raw_data = await self._client.get_paramset(
683
+ address=sca,
684
+ paramset_key=ParamsetKey.MASTER,
685
+ )
686
+ except ClientException as cex:
687
+ raise ValidationException(
688
+ i18n.tr(
689
+ key="exception.model.week_profile.schedule.unsupported",
690
+ name=self._device.name,
691
+ )
692
+ ) from cex
693
+
694
+ if not (schedule := self._convert_schedule_entries(values=raw_data)):
695
+ raise ValidationException(
696
+ i18n.tr(
697
+ key="exception.model.week_profile.schedule.unsupported",
698
+ name=self._device.name,
699
+ )
700
+ )
701
+ return schedule
702
+
703
+
704
+ class ClimateWeekProfile(WeekProfile[ClimateScheduleDict]):
705
+ """
706
+ Handle climate device week profiles (thermostats).
707
+
708
+ This class manages heating/cooling schedules with time slots and temperature settings.
709
+ Supports multiple profiles (P1-P6) with 13 time slots per weekday.
710
+ Provides both raw and simplified schedule interfaces for easy temperature programming.
711
+ """
712
+
713
+ _data_point: BaseCustomDpClimate
714
+ __slots__ = (
715
+ "_max_temp",
716
+ "_min_temp",
717
+ )
718
+
719
+ def __init__(self, *, data_point: CustomDataPointProtocol) -> None:
720
+ """Initialize the climate week profile."""
721
+ super().__init__(data_point=data_point)
722
+ self._min_temp: Final[float] = self._data_point.min_temp
723
+ self._max_temp: Final[float] = self._data_point.max_temp
724
+
725
+ @staticmethod
726
+ def convert_dict_to_raw_schedule(*, schedule_data: ClimateScheduleDict) -> RAW_SCHEDULE_DICT:
727
+ """
728
+ Convert structured climate schedule to raw paramset format.
729
+
730
+ Args:
731
+ schedule_data: Structured schedule with profiles, weekdays, and time slots
732
+
733
+ Returns:
734
+ Raw schedule dictionary for CCU transmission
735
+
736
+ Example:
737
+ Input: {ScheduleProfile.P1: {"MONDAY": {1: {"temperature": 20.0, "endtime": "06:00"}}}}
738
+ Output: {"P1_TEMPERATURE_MONDAY_1": 20.0, "P1_ENDTIME_MONDAY_1": 360}
739
+
740
+ """
741
+ raw_paramset: RAW_SCHEDULE_DICT = {}
742
+ for profile, profile_data in schedule_data.items():
743
+ for weekday, weekday_data in profile_data.items():
744
+ for slot_no, slot in weekday_data.items():
745
+ for slot_type, slot_value in slot.items():
746
+ # Convert lowercase slot_type to uppercase for CCU format
747
+ raw_profile_name = f"{str(profile)}_{str(slot_type).upper()}_{str(weekday)}_{slot_no}"
748
+ if SCHEDULER_PROFILE_PATTERN.match(raw_profile_name) is None:
749
+ raise ValidationException(
750
+ i18n.tr(
751
+ key="exception.model.week_profile.validate.profile_name_invalid",
752
+ profile_name=raw_profile_name,
753
+ )
754
+ )
755
+ raw_value: float | int = cast(float | int, slot_value)
756
+ if slot_type == "endtime" and isinstance(slot_value, str):
757
+ raw_value = _convert_time_str_to_minutes(time_str=slot_value)
758
+ raw_paramset[raw_profile_name] = raw_value
759
+ return raw_paramset
760
+
761
+ @staticmethod
762
+ def convert_raw_to_dict_schedule(*, raw_schedule: RAW_SCHEDULE_DICT) -> ClimateScheduleDict:
763
+ """
764
+ Convert raw CCU schedule to structured dictionary format.
765
+
766
+ Args:
767
+ raw_schedule: Raw schedule from CCU paramset
768
+
769
+ Returns:
770
+ Structured schedule grouped by profile, weekday, and slot
771
+
772
+ Example:
773
+ Input: {"P1_TEMPERATURE_MONDAY_1": 20.0, "P1_ENDTIME_MONDAY_1": 360}
774
+ Output: {ScheduleProfile.P1: {"MONDAY": {1: {"temperature": 20.0, "endtime": "06:00"}}}}
775
+
776
+ """
777
+ # Use permissive type during incremental construction, final type is ClimateScheduleDict
778
+ schedule_data: dict[ScheduleProfile, dict[WeekdayStr, dict[int, dict[str, str | float]]]] = {}
779
+
780
+ # Process each schedule entry
781
+ for slot_name, slot_value in raw_schedule.items():
782
+ # Split string only once, use maxsplit for micro-optimization
783
+ # Expected format: "P1_TEMPERATURE_MONDAY_1"
784
+ parts = slot_name.split("_", 3) # maxsplit=3 limits splits
785
+ if len(parts) != 4:
786
+ continue
787
+
788
+ profile_name, slot_type_name, slot_weekday_name, slot_no_str = parts
789
+
790
+ try:
791
+ _profile = ScheduleProfile(profile_name)
792
+ # Convert slot type to lowercase string instead of enum
793
+ _slot_type = slot_type_name.lower()
794
+ _weekday = WeekdayStr(slot_weekday_name)
795
+ _slot_no = int(slot_no_str)
796
+ except (ValueError, KeyError):
797
+ # Gracefully skip invalid entries instead of crashing
798
+ continue
799
+
800
+ if _profile not in schedule_data:
801
+ schedule_data[_profile] = {}
802
+ if _weekday not in schedule_data[_profile]:
803
+ schedule_data[_profile][_weekday] = {}
804
+ if _slot_no not in schedule_data[_profile][_weekday]:
805
+ schedule_data[_profile][_weekday][_slot_no] = {}
806
+
807
+ # Convert ENDTIME from minutes to time string if needed
808
+ final_value: str | float = slot_value
809
+ if _slot_type == "endtime" and isinstance(slot_value, int):
810
+ final_value = _convert_minutes_to_time_str(minutes=slot_value)
811
+
812
+ schedule_data[_profile][_weekday][_slot_no][_slot_type] = final_value
813
+
814
+ # Cast to ClimateScheduleDict since we built it with all required keys
815
+ return cast(ClimateScheduleDict, schedule_data)
816
+
817
+ @property
818
+ def available_schedule_profiles(self) -> tuple[ScheduleProfile, ...]:
819
+ """Return the available schedule profiles."""
820
+ return tuple(self._schedule_cache.keys())
821
+
822
+ @property
823
+ def schedule(self) -> ClimateScheduleDict:
824
+ """Return the schedule cache."""
825
+ return _filter_schedule_entries(schedule_data=self._schedule_cache)
826
+
827
+ @property
828
+ def simple_schedule(self) -> SimpleScheduleDict:
829
+ """Return schedule in TypedDict format with string keys for JSON compatibility."""
830
+ return self._validate_and_convert_schedule_to_simple(schedule_data=self._schedule_cache)
831
+
832
+ @inspector
833
+ async def copy_profile(
834
+ self,
835
+ *,
836
+ source_profile: ScheduleProfile,
837
+ target_profile: ScheduleProfile,
838
+ target_climate_data_point: BaseCustomDpClimate | None = None,
839
+ ) -> None:
840
+ """Copy schedule profile to target device."""
841
+ same_device = False
842
+ if not self.has_schedule:
843
+ raise ValidationException(
844
+ i18n.tr(
845
+ key="exception.model.week_profile.schedule.unsupported",
846
+ name=self._device.name,
847
+ )
848
+ )
849
+ if target_climate_data_point is None:
850
+ target_climate_data_point = self._data_point
851
+ if self._data_point is target_climate_data_point:
852
+ same_device = True
853
+
854
+ if same_device and (source_profile == target_profile or (source_profile is None or target_profile is None)):
855
+ raise ValidationException(i18n.tr(key="exception.model.week_profile.copy_schedule.same_device_invalid"))
856
+
857
+ if (source_profile_data := await self.get_profile(profile=source_profile)) is None:
858
+ raise ValidationException(
859
+ i18n.tr(
860
+ key="exception.model.week_profile.source_profile.not_loaded",
861
+ source_profile=source_profile,
862
+ )
863
+ )
864
+ if not target_climate_data_point.device.has_week_profile:
865
+ raise ValidationException(
866
+ i18n.tr(
867
+ key="exception.model.week_profile.schedule.unsupported",
868
+ address=self._device.name,
869
+ )
870
+ )
871
+ if (
872
+ target_climate_data_point.device.week_profile
873
+ and (sca := target_climate_data_point.device.week_profile.schedule_channel_address) is not None
874
+ ):
875
+ await self._set_schedule_profile(
876
+ target_channel_address=sca,
877
+ profile=target_profile,
878
+ profile_data=source_profile_data,
879
+ do_validate=False,
880
+ )
881
+
882
+ @inspector
883
+ async def copy_schedule(self, *, target_climate_data_point: BaseCustomDpClimate) -> None:
884
+ """Copy schedule to target device."""
885
+ if self._data_point.schedule_profile_nos != target_climate_data_point.schedule_profile_nos:
886
+ raise ValidationException(i18n.tr(key="exception.model.week_profile.copy_schedule.profile_count_mismatch"))
887
+ raw_schedule = await self._get_raw_schedule()
888
+ if not target_climate_data_point.device.has_week_profile:
889
+ raise ValidationException(
890
+ i18n.tr(
891
+ key="exception.model.week_profile.schedule.unsupported",
892
+ address=self._device.name,
893
+ )
894
+ )
895
+ if (
896
+ self._data_point.device.week_profile
897
+ and (sca := self._data_point.device.week_profile.schedule_channel_address) is not None
898
+ ):
899
+ await self._client.put_paramset(
900
+ channel_address=sca,
901
+ paramset_key_or_link_address=ParamsetKey.MASTER,
902
+ values=raw_schedule,
903
+ )
904
+
905
+ @inspector
906
+ async def get_profile(self, *, profile: ScheduleProfile, force_load: bool = False) -> ClimateProfileSchedule:
907
+ """Return a schedule by climate profile."""
908
+ if not self.has_schedule:
909
+ raise ValidationException(
910
+ i18n.tr(
911
+ key="exception.model.week_profile.schedule.unsupported",
912
+ name=self._device.name,
913
+ )
914
+ )
915
+ if force_load or not self._schedule_cache:
916
+ await self.reload_and_cache_schedule()
917
+ return _filter_profile_entries(profile_data=self._schedule_cache.get(profile, {}))
918
+
919
+ @inspector
920
+ async def get_schedule(self, *, force_load: bool = False) -> ClimateScheduleDict:
921
+ """Return the complete schedule dictionary."""
922
+ if not self.has_schedule:
923
+ raise ValidationException(
924
+ i18n.tr(
925
+ key="exception.model.week_profile.schedule.unsupported",
926
+ name=self._device.name,
927
+ )
928
+ )
929
+ if force_load or not self._schedule_cache:
930
+ await self.reload_and_cache_schedule()
931
+ return _filter_schedule_entries(schedule_data=self._schedule_cache)
932
+
933
+ @inspector
934
+ async def get_simple_profile(self, *, profile: ScheduleProfile, force_load: bool = False) -> SimpleProfileSchedule:
935
+ """Return a simple schedule by climate profile."""
936
+ if not self.has_schedule:
937
+ raise ValidationException(
938
+ i18n.tr(
939
+ key="exception.model.week_profile.schedule.unsupported",
940
+ name=self._device.name,
941
+ )
942
+ )
943
+ if force_load or not self._schedule_cache:
944
+ await self.reload_and_cache_schedule()
945
+ return self._validate_and_convert_profile_to_simple(profile_data=self._schedule_cache.get(profile, {}))
946
+
947
+ @inspector
948
+ async def get_simple_schedule(self, *, force_load: bool = False) -> SimpleScheduleDict:
949
+ """Return the complete simple schedule dictionary."""
950
+ if not self.has_schedule:
951
+ raise ValidationException(
952
+ i18n.tr(
953
+ key="exception.model.week_profile.schedule.unsupported",
954
+ name=self._device.name,
955
+ )
956
+ )
957
+ if force_load or not self._schedule_cache:
958
+ await self.reload_and_cache_schedule()
959
+ return self._validate_and_convert_schedule_to_simple(schedule_data=self._schedule_cache)
960
+
961
+ @inspector
962
+ async def get_simple_weekday(
963
+ self, *, profile: ScheduleProfile, weekday: WeekdayStr, force_load: bool = False
964
+ ) -> SimpleWeekdaySchedule:
965
+ """Return a simple schedule by climate profile and weekday."""
966
+ if not self.has_schedule:
967
+ raise ValidationException(
968
+ i18n.tr(
969
+ key="exception.model.week_profile.schedule.unsupported",
970
+ name=self._device.name,
971
+ )
972
+ )
973
+ if force_load or not self._schedule_cache:
974
+ await self.reload_and_cache_schedule()
975
+ return self._validate_and_convert_weekday_to_simple(
976
+ weekday_data=self._schedule_cache.get(profile, {}).get(weekday, {})
977
+ )
978
+
979
+ @inspector
980
+ async def get_weekday(
981
+ self, *, profile: ScheduleProfile, weekday: WeekdayStr, force_load: bool = False
982
+ ) -> ClimateWeekdaySchedule:
983
+ """Return a schedule by climate profile."""
984
+ if not self.has_schedule:
985
+ raise ValidationException(
986
+ i18n.tr(
987
+ key="exception.model.week_profile.schedule.unsupported",
988
+ name=self._device.name,
989
+ )
990
+ )
991
+ if force_load or not self._schedule_cache:
992
+ await self.reload_and_cache_schedule()
993
+ return _filter_weekday_entries(weekday_data=self._schedule_cache.get(profile, {}).get(weekday, {}))
994
+
995
+ async def reload_and_cache_schedule(self, *, force: bool = False) -> None:
996
+ """Reload schedules from CCU and update cache, publish events if changed."""
997
+ if not self.has_schedule:
998
+ return
999
+
1000
+ try:
1001
+ new_schedule = await self._get_schedule_profile()
1002
+ except ValidationException:
1003
+ _LOGGER.debug(
1004
+ "RELOAD_AND_CACHE_SCHEDULE: Failed to reload schedules for %s",
1005
+ self._device.name,
1006
+ )
1007
+ return
1008
+
1009
+ # Compare old and new schedules
1010
+ old_schedule = self._schedule_cache
1011
+ # Update cache with new schedules
1012
+ self._schedule_cache = new_schedule
1013
+ if old_schedule != new_schedule:
1014
+ _LOGGER.debug(
1015
+ "RELOAD_AND_CACHE_SCHEDULE: Schedule changed for %s, publishing events",
1016
+ self._device.name,
1017
+ )
1018
+ # Publish data point updated event to trigger handlers
1019
+ self._data_point.publish_data_point_updated_event()
1020
+
1021
+ @inspector
1022
+ async def set_profile(
1023
+ self, *, profile: ScheduleProfile, profile_data: ClimateProfileSchedule, do_validate: bool = True
1024
+ ) -> None:
1025
+ """Set a profile to device."""
1026
+ sca = self._validate_and_get_schedule_channel_address()
1027
+ await self._set_schedule_profile(
1028
+ target_channel_address=sca,
1029
+ profile=profile,
1030
+ profile_data=profile_data,
1031
+ do_validate=do_validate,
1032
+ )
1033
+
1034
+ @inspector
1035
+ async def set_schedule(self, *, schedule_data: ClimateScheduleDict) -> None:
1036
+ """Set the complete schedule dictionary to device."""
1037
+ sca = self._validate_and_get_schedule_channel_address()
1038
+
1039
+ # Update cache and publish event
1040
+ old_schedule = self._schedule_cache
1041
+ self._schedule_cache.update(schedule_data)
1042
+ if old_schedule != self._schedule_cache:
1043
+ self._data_point.publish_data_point_updated_event()
1044
+
1045
+ # Write to device
1046
+ await self._client.put_paramset(
1047
+ channel_address=sca,
1048
+ paramset_key_or_link_address=ParamsetKey.MASTER,
1049
+ values=self.convert_dict_to_raw_schedule(schedule_data=schedule_data),
1050
+ )
1051
+
1052
+ @inspector
1053
+ async def set_simple_profile(
1054
+ self,
1055
+ *,
1056
+ profile: ScheduleProfile,
1057
+ simple_profile_data: SimpleProfileSchedule,
1058
+ ) -> None:
1059
+ """Set a profile to device."""
1060
+ profile_data = self._validate_and_convert_simple_to_profile(simple_profile_data=simple_profile_data)
1061
+ await self.set_profile(profile=profile, profile_data=profile_data)
1062
+
1063
+ @inspector
1064
+ async def set_simple_schedule(self, *, simple_schedule_data: SimpleScheduleDict) -> None:
1065
+ """Set the complete simple schedule dictionary to device."""
1066
+ # Convert simple schedule to full schedule format
1067
+ schedule_data = self._validate_and_convert_simple_to_schedule(simple_schedule_data=simple_schedule_data)
1068
+ await self.set_schedule(schedule_data=schedule_data)
1069
+
1070
+ @inspector
1071
+ async def set_simple_weekday(
1072
+ self,
1073
+ *,
1074
+ profile: ScheduleProfile,
1075
+ weekday: WeekdayStr,
1076
+ simple_weekday_data: SimpleWeekdaySchedule,
1077
+ ) -> None:
1078
+ """Store a simple weekday profile to device."""
1079
+ weekday_data = self._validate_and_convert_simple_to_weekday(simple_weekday_data=simple_weekday_data)
1080
+ await self.set_weekday(profile=profile, weekday=weekday, weekday_data=weekday_data)
1081
+
1082
+ @inspector
1083
+ async def set_weekday(
1084
+ self,
1085
+ *,
1086
+ profile: ScheduleProfile,
1087
+ weekday: WeekdayStr,
1088
+ weekday_data: ClimateWeekdaySchedule,
1089
+ do_validate: bool = True,
1090
+ ) -> None:
1091
+ """Store a profile to device."""
1092
+ # Normalize weekday_data: convert string keys to int and sort by ENDTIME
1093
+ weekday_data = _normalize_weekday_data(weekday_data=weekday_data)
1094
+
1095
+ if do_validate:
1096
+ self._validate_weekday(profile=profile, weekday=weekday, weekday_data=weekday_data)
1097
+
1098
+ if weekday_data != self._schedule_cache.get(profile, {}).get(weekday, {}):
1099
+ if profile not in self._schedule_cache:
1100
+ self._schedule_cache[profile] = {}
1101
+ self._schedule_cache[profile][weekday] = weekday_data
1102
+ self._data_point.publish_data_point_updated_event()
1103
+
1104
+ sca = self._validate_and_get_schedule_channel_address()
1105
+ await self._client.put_paramset(
1106
+ channel_address=sca,
1107
+ paramset_key_or_link_address=ParamsetKey.MASTER,
1108
+ values=self.convert_dict_to_raw_schedule(schedule_data={profile: {weekday: weekday_data}}),
1109
+ )
1110
+
1111
+ async def _get_raw_schedule(self) -> RAW_SCHEDULE_DICT:
1112
+ """Return the raw schedule."""
1113
+ try:
1114
+ sca = self._validate_and_get_schedule_channel_address()
1115
+ raw_data = await self._client.get_paramset(
1116
+ address=sca,
1117
+ paramset_key=ParamsetKey.MASTER,
1118
+ )
1119
+ raw_schedule = {key: value for key, value in raw_data.items() if SCHEDULER_PROFILE_PATTERN.match(key)}
1120
+ except ClientException as cex:
1121
+ raise ValidationException(
1122
+ i18n.tr(
1123
+ key="exception.model.week_profile.schedule.unsupported",
1124
+ name=self._device.name,
1125
+ )
1126
+ ) from cex
1127
+ return raw_schedule
1128
+
1129
+ async def _get_schedule_profile(self) -> ClimateScheduleDict:
1130
+ """Get the schedule."""
1131
+ # Get raw schedule data from device
1132
+ raw_schedule = await self._get_raw_schedule()
1133
+ return self.convert_raw_to_dict_schedule(raw_schedule=raw_schedule)
1134
+
1135
+ async def _set_schedule_profile(
1136
+ self,
1137
+ *,
1138
+ target_channel_address: str,
1139
+ profile: ScheduleProfile,
1140
+ profile_data: ClimateProfileSchedule,
1141
+ do_validate: bool,
1142
+ ) -> None:
1143
+ """Set a profile to device."""
1144
+ # Normalize weekday_data: convert string keys to int and sort by ENDTIME
1145
+ profile_data = {
1146
+ weekday: _normalize_weekday_data(weekday_data=weekday_data)
1147
+ for weekday, weekday_data in profile_data.items()
1148
+ }
1149
+ if do_validate:
1150
+ self._validate_profile(profile=profile, profile_data=profile_data)
1151
+ if profile_data != self._schedule_cache.get(profile, {}):
1152
+ self._schedule_cache[profile] = profile_data
1153
+ self._data_point.publish_data_point_updated_event()
1154
+
1155
+ await self._client.put_paramset(
1156
+ channel_address=target_channel_address,
1157
+ paramset_key_or_link_address=ParamsetKey.MASTER,
1158
+ values=self.convert_dict_to_raw_schedule(schedule_data={profile: profile_data}),
1159
+ )
1160
+
1161
+ def _validate_and_convert_profile_to_simple(self, *, profile_data: ClimateProfileSchedule) -> SimpleProfileSchedule:
1162
+ """Convert a full climate profile to simplified TypedDict format."""
1163
+ simple_profile: SimpleProfileSchedule = {}
1164
+ for weekday, weekday_data in profile_data.items():
1165
+ simple_profile[weekday] = self._validate_and_convert_weekday_to_simple(weekday_data=weekday_data)
1166
+ return simple_profile
1167
+
1168
+ def _validate_and_convert_schedule_to_simple(self, *, schedule_data: ClimateScheduleDict) -> SimpleScheduleDict:
1169
+ """Convert a full schedule to simplified TypedDict format."""
1170
+ simple_schedule: SimpleScheduleDict = {}
1171
+ for profile, profile_data in schedule_data.items():
1172
+ simple_schedule[profile] = self._validate_and_convert_profile_to_simple(profile_data=profile_data)
1173
+ return simple_schedule
1174
+
1175
+ def _validate_and_convert_simple_to_profile(
1176
+ self, *, simple_profile_data: SimpleProfileSchedule
1177
+ ) -> ClimateProfileSchedule:
1178
+ """Convert simple profile TypedDict to full profile dict."""
1179
+ profile_data: ClimateProfileSchedule = {}
1180
+ for day, simple_weekday_data in simple_profile_data.items():
1181
+ profile_data[day] = self._validate_and_convert_simple_to_weekday(simple_weekday_data=simple_weekday_data)
1182
+ return profile_data
1183
+
1184
+ def _validate_and_convert_simple_to_schedule(
1185
+ self, *, simple_schedule_data: SimpleScheduleDict
1186
+ ) -> ClimateScheduleDict:
1187
+ """Convert simple schedule TypedDict to full schedule dict."""
1188
+ schedule_data: ClimateScheduleDict = {}
1189
+ for profile, profile_data in simple_schedule_data.items():
1190
+ schedule_data[profile] = self._validate_and_convert_simple_to_profile(simple_profile_data=profile_data)
1191
+ return schedule_data
1192
+
1193
+ def _validate_and_convert_simple_to_weekday(
1194
+ self, *, simple_weekday_data: SimpleWeekdaySchedule
1195
+ ) -> ClimateWeekdaySchedule:
1196
+ """Convert simple weekday TypedDict to full weekday dict."""
1197
+ base_temperature = simple_weekday_data["base_temperature"]
1198
+ _weekday_data = simple_weekday_data["periods"]
1199
+
1200
+ if not self._min_temp <= base_temperature <= self._max_temp:
1201
+ raise ValidationException(
1202
+ i18n.tr(
1203
+ key="exception.model.week_profile.validate.base_temperature_out_of_range",
1204
+ base_temperature=base_temperature,
1205
+ min=self._min_temp,
1206
+ max=self._max_temp,
1207
+ )
1208
+ )
1209
+
1210
+ weekday_data: ClimateWeekdaySchedule = {}
1211
+
1212
+ # Validate required fields before sorting
1213
+ for slot in _weekday_data:
1214
+ if (starttime := slot.get("starttime")) is None:
1215
+ raise ValidationException(i18n.tr(key="exception.model.week_profile.validate.starttime_missing"))
1216
+ if (endtime := slot.get("endtime")) is None:
1217
+ raise ValidationException(i18n.tr(key="exception.model.week_profile.validate.endtime_missing"))
1218
+ if (temperature := slot.get("temperature")) is None:
1219
+ raise ValidationException(i18n.tr(key="exception.model.week_profile.validate.temperature_missing"))
1220
+
1221
+ sorted_periods = sorted(_weekday_data, key=lambda p: _convert_time_str_to_minutes(time_str=p["starttime"]))
1222
+ previous_endtime = CLIMATE_MIN_SCHEDULER_TIME
1223
+ slot_no = 1
1224
+ for slot in sorted_periods:
1225
+ starttime = slot["starttime"]
1226
+ endtime = slot["endtime"]
1227
+ temperature = slot["temperature"]
1228
+
1229
+ if _convert_time_str_to_minutes(time_str=str(starttime)) >= _convert_time_str_to_minutes(
1230
+ time_str=str(endtime)
1231
+ ):
1232
+ raise ValidationException(
1233
+ i18n.tr(
1234
+ key="exception.model.week_profile.validate.start_before_end",
1235
+ start=starttime,
1236
+ end=endtime,
1237
+ )
1238
+ )
1239
+
1240
+ if _convert_time_str_to_minutes(time_str=str(starttime)) < _convert_time_str_to_minutes(
1241
+ time_str=previous_endtime
1242
+ ):
1243
+ raise ValidationException(
1244
+ i18n.tr(
1245
+ key="exception.model.week_profile.validate.overlap",
1246
+ start=starttime,
1247
+ end=endtime,
1248
+ )
1249
+ )
1250
+
1251
+ if not self._min_temp <= float(temperature) <= self._max_temp:
1252
+ raise ValidationException(
1253
+ i18n.tr(
1254
+ key="exception.model.week_profile.validate.temperature_out_of_range_for_times",
1255
+ temperature=temperature,
1256
+ min=self._min_temp,
1257
+ max=self._max_temp,
1258
+ start=starttime,
1259
+ end=endtime,
1260
+ )
1261
+ )
1262
+
1263
+ if _convert_time_str_to_minutes(time_str=str(starttime)) > _convert_time_str_to_minutes(
1264
+ time_str=previous_endtime
1265
+ ):
1266
+ weekday_data[slot_no] = {
1267
+ "endtime": starttime,
1268
+ "temperature": base_temperature,
1269
+ }
1270
+ slot_no += 1
1271
+
1272
+ weekday_data[slot_no] = {
1273
+ "endtime": endtime,
1274
+ "temperature": temperature,
1275
+ }
1276
+ previous_endtime = str(endtime)
1277
+ slot_no += 1
1278
+
1279
+ return _fillup_weekday_data(base_temperature=base_temperature, weekday_data=weekday_data)
1280
+
1281
+ def _validate_and_convert_weekday_to_simple(self, *, weekday_data: ClimateWeekdaySchedule) -> SimpleWeekdaySchedule:
1282
+ """
1283
+ Convert a full weekday (13 slots) to a simplified TypedDict format.
1284
+
1285
+ Returns:
1286
+ SimpleWeekdaySchedule with base_temperature and periods list
1287
+
1288
+ """
1289
+ base_temperature = identify_base_temperature(weekday_data=weekday_data)
1290
+
1291
+ # filter out irrelevant entries
1292
+ filtered_data = _filter_weekday_entries(weekday_data=weekday_data)
1293
+
1294
+ if not self._min_temp <= float(base_temperature) <= self._max_temp:
1295
+ raise ValidationException(
1296
+ i18n.tr(
1297
+ key="exception.model.week_profile.validate.base_temperature_out_of_range",
1298
+ base_temperature=base_temperature,
1299
+ min=self._min_temp,
1300
+ max=self._max_temp,
1301
+ )
1302
+ )
1303
+
1304
+ # Normalize and perform basic validation using existing helper
1305
+ normalized = _normalize_weekday_data(weekday_data=filtered_data)
1306
+
1307
+ # Build simple list by merging consecutive non-base temperature slots
1308
+ periods: list[SimpleSchedulePeriod] = []
1309
+ previous_end = CLIMATE_MIN_SCHEDULER_TIME
1310
+ open_range: SimpleSchedulePeriod | None = None
1311
+ last_temp: float | None = None
1312
+
1313
+ for no in sorted(normalized.keys()):
1314
+ slot = normalized[no]
1315
+ endtime_str = str(slot["endtime"])
1316
+ temp = float(slot["temperature"])
1317
+
1318
+ # If time decreases from previous, the weekday is invalid
1319
+ if _convert_time_str_to_minutes(time_str=endtime_str) < _convert_time_str_to_minutes(
1320
+ time_str=str(previous_end)
1321
+ ):
1322
+ raise ValidationException(
1323
+ i18n.tr(
1324
+ key="exception.model.week_profile.validate.time_out_of_bounds_profile_slot",
1325
+ time=endtime_str,
1326
+ min_time=CLIMATE_MIN_SCHEDULER_TIME,
1327
+ max_time=CLIMATE_MAX_SCHEDULER_TIME,
1328
+ profile="-",
1329
+ weekday="-",
1330
+ no=no,
1331
+ )
1332
+ )
1333
+
1334
+ # Ignore base temperature segments; track/merge non-base
1335
+ if temp != float(base_temperature):
1336
+ if open_range is None:
1337
+ # start new range from previous_end
1338
+ open_range = SimpleSchedulePeriod(
1339
+ starttime=str(previous_end),
1340
+ endtime=endtime_str,
1341
+ temperature=temp,
1342
+ )
1343
+ last_temp = temp
1344
+ # extend if same temperature
1345
+ elif temp == last_temp:
1346
+ open_range = SimpleSchedulePeriod(
1347
+ starttime=open_range["starttime"],
1348
+ endtime=endtime_str,
1349
+ temperature=temp,
1350
+ )
1351
+ else:
1352
+ # temperature changed: close previous and start new
1353
+ periods.append(open_range)
1354
+ open_range = SimpleSchedulePeriod(
1355
+ starttime=str(previous_end),
1356
+ endtime=endtime_str,
1357
+ temperature=temp,
1358
+ )
1359
+ last_temp = temp
1360
+
1361
+ # closing any open non-base range when hitting base segment
1362
+ elif open_range is not None:
1363
+ periods.append(open_range)
1364
+ open_range = None
1365
+ last_temp = None
1366
+
1367
+ previous_end = endtime_str
1368
+
1369
+ # After last slot, if we still have an open range, close it
1370
+ if open_range is not None:
1371
+ periods.append(open_range)
1372
+
1373
+ # Sort by start time
1374
+ if periods:
1375
+ periods = sorted(periods, key=lambda p: _convert_time_str_to_minutes(time_str=p["starttime"]))
1376
+
1377
+ return SimpleWeekdaySchedule(base_temperature=base_temperature, periods=periods)
1378
+
1379
+ def _validate_profile(self, *, profile: ScheduleProfile, profile_data: ClimateProfileSchedule) -> None:
1380
+ """Validate the profile."""
1381
+ for weekday, weekday_data in profile_data.items():
1382
+ self._validate_weekday(profile=profile, weekday=weekday, weekday_data=weekday_data)
1383
+
1384
+ def _validate_weekday(
1385
+ self,
1386
+ *,
1387
+ profile: ScheduleProfile,
1388
+ weekday: WeekdayStr,
1389
+ weekday_data: ClimateWeekdaySchedule,
1390
+ ) -> None:
1391
+ """Validate the profile weekday."""
1392
+ previous_endtime = 0
1393
+ if len(weekday_data) != 13:
1394
+ if len(weekday_data) > 13:
1395
+ raise ValidationException(
1396
+ i18n.tr(
1397
+ key="exception.model.week_profile.validate.too_many_slots",
1398
+ profile=profile,
1399
+ weekday=weekday,
1400
+ )
1401
+ )
1402
+ raise ValidationException(
1403
+ i18n.tr(
1404
+ key="exception.model.week_profile.validate.too_few_slots",
1405
+ profile=profile,
1406
+ weekday=weekday,
1407
+ )
1408
+ )
1409
+ for no in CLIMATE_SCHEDULE_SLOT_RANGE:
1410
+ if no not in weekday_data:
1411
+ raise ValidationException(
1412
+ i18n.tr(
1413
+ key="exception.model.week_profile.validate.slot_missing",
1414
+ no=no,
1415
+ profile=profile,
1416
+ weekday=weekday,
1417
+ )
1418
+ )
1419
+ slot = weekday_data[no]
1420
+ for slot_type in CLIMATE_RELEVANT_SLOT_TYPES:
1421
+ if slot_type not in slot:
1422
+ raise ValidationException(
1423
+ i18n.tr(
1424
+ key="exception.model.week_profile.validate.slot_type_missing",
1425
+ slot_type=slot_type,
1426
+ profile=profile,
1427
+ weekday=weekday,
1428
+ no=no,
1429
+ )
1430
+ )
1431
+
1432
+ # Validate temperature
1433
+ temperature = float(weekday_data[no]["temperature"])
1434
+ if not self._min_temp <= temperature <= self._max_temp:
1435
+ raise ValidationException(
1436
+ i18n.tr(
1437
+ key="exception.model.week_profile.validate.temperature_out_of_range_for_profile_slot",
1438
+ temperature=temperature,
1439
+ min=self._min_temp,
1440
+ max=self._max_temp,
1441
+ profile=profile,
1442
+ weekday=weekday,
1443
+ no=no,
1444
+ )
1445
+ )
1446
+
1447
+ # Validate endtime
1448
+ endtime_str = str(weekday_data[no]["endtime"])
1449
+ if endtime := _convert_time_str_to_minutes(time_str=endtime_str):
1450
+ if endtime not in CLIMATE_SCHEDULE_TIME_RANGE:
1451
+ raise ValidationException(
1452
+ i18n.tr(
1453
+ key="exception.model.week_profile.validate.time_out_of_bounds_profile_slot",
1454
+ time=endtime_str,
1455
+ min_time=_convert_minutes_to_time_str(minutes=CLIMATE_SCHEDULE_TIME_RANGE.start),
1456
+ max_time=_convert_minutes_to_time_str(minutes=CLIMATE_SCHEDULE_TIME_RANGE.stop - 1),
1457
+ profile=profile,
1458
+ weekday=weekday,
1459
+ no=no,
1460
+ )
1461
+ )
1462
+ if endtime < previous_endtime:
1463
+ raise ValidationException(
1464
+ i18n.tr(
1465
+ key="exception.model.week_profile.validate.sequence_rising",
1466
+ time=endtime_str,
1467
+ previous=_convert_minutes_to_time_str(minutes=previous_endtime),
1468
+ profile=profile,
1469
+ weekday=weekday,
1470
+ no=no,
1471
+ )
1472
+ )
1473
+ previous_endtime = endtime
1474
+
1475
+
1476
+ def create_week_profile(*, data_point: CustomDataPointProtocol) -> WeekProfile[dict[Any, Any]]:
1477
+ """Create a week profile from a custom data point."""
1478
+ if data_point.category == DataPointCategory.CLIMATE:
1479
+ return ClimateWeekProfile(data_point=data_point)
1480
+ return DefaultWeekProfile(data_point=data_point)
1481
+
1482
+
1483
+ def _bitwise_to_list(*, value: int, enum_class: type[IntEnum]) -> list[IntEnum]:
1484
+ """
1485
+ Convert bitwise integer to list of enum values.
1486
+
1487
+ Example:
1488
+ _bitwise_to_list(127, Weekday) -> [SUNDAY, MONDAY, ..., SATURDAY]
1489
+ _bitwise_to_list(7, Channel) -> [CHANNEL_1, CHANNEL_2, CHANNEL_3]
1490
+
1491
+ """
1492
+ if value == 0:
1493
+ return []
1494
+
1495
+ return [item for item in enum_class if value & item.value]
1496
+
1497
+
1498
+ def _filter_profile_entries(*, profile_data: ClimateProfileSchedule) -> ClimateProfileSchedule:
1499
+ """Filter profile data to remove redundant 24:00 slots."""
1500
+ if not profile_data:
1501
+ return profile_data
1502
+
1503
+ filtered_data = {}
1504
+ for weekday, weekday_data in profile_data.items():
1505
+ if filtered_weekday := _filter_weekday_entries(weekday_data=weekday_data):
1506
+ filtered_data[weekday] = filtered_weekday
1507
+
1508
+ return filtered_data
1509
+
1510
+
1511
+ def _filter_schedule_entries(*, schedule_data: ClimateScheduleDict) -> ClimateScheduleDict:
1512
+ """Filter schedule data to remove redundant 24:00 slots."""
1513
+ if not schedule_data:
1514
+ return schedule_data
1515
+
1516
+ result: ClimateScheduleDict = {}
1517
+ for profile, profile_data in schedule_data.items():
1518
+ if filtered_profile := _filter_profile_entries(profile_data=profile_data):
1519
+ result[profile] = filtered_profile
1520
+ return result
1521
+
1522
+
1523
+ def _filter_weekday_entries(*, weekday_data: ClimateWeekdaySchedule) -> ClimateWeekdaySchedule:
1524
+ """
1525
+ Filter weekday data to remove redundant 24:00 slots.
1526
+
1527
+ Processes slots in slot-number order and stops at the first occurrence of 24:00.
1528
+ Any slots after the first 24:00 are ignored, regardless of their endtime.
1529
+ This matches the behavior of homematicip_local_climate_scheduler_card.
1530
+ """
1531
+ if not weekday_data:
1532
+ return weekday_data
1533
+
1534
+ # Sort slots by slot number only (not by endtime)
1535
+ sorted_slots = sorted(weekday_data.items(), key=lambda item: item[0])
1536
+
1537
+ filtered_slots = []
1538
+
1539
+ for _slot_num, slot in sorted_slots:
1540
+ endtime = slot.get("endtime", "")
1541
+
1542
+ # Add this slot to the filtered list
1543
+ filtered_slots.append(slot)
1544
+
1545
+ # Stop at the first occurrence of 24:00 - ignore all subsequent slots
1546
+ if endtime == CLIMATE_MAX_SCHEDULER_TIME:
1547
+ break
1548
+
1549
+ # Renumber slots to be sequential (1, 2, 3, ...)
1550
+ if filtered_slots:
1551
+ return dict(enumerate(filtered_slots, start=1))
1552
+ return {}
1553
+
1554
+
1555
+ def _list_to_bitwise(*, items: list[IntEnum]) -> int:
1556
+ """
1557
+ Convert list of enum values to bitwise integer.
1558
+
1559
+ Example:
1560
+ _list_to_bitwise([Weekday.MONDAY, Weekday.FRIDAY]) -> 34
1561
+ _list_to_bitwise([Channel.CHANNEL_1, Channel.CHANNEL_3]) -> 5
1562
+
1563
+ """
1564
+ if not items:
1565
+ return 0
1566
+
1567
+ result = 0
1568
+ for item in items:
1569
+ result |= item.value
1570
+ return result
1571
+
1572
+
1573
+ def is_schedule_active(*, group_data: DEFAULT_SCHEDULE_GROUP) -> bool:
1574
+ """
1575
+ Check if a schedule group will actually execute (not deactivated).
1576
+
1577
+ Args:
1578
+ group_data: Schedule group data
1579
+
1580
+ Returns:
1581
+ True if schedule has both weekdays and target channels configured,
1582
+ False if deactivated or incomplete
1583
+
1584
+ Note:
1585
+ A schedule is considered active only if it has both:
1586
+ - At least one weekday selected (when to run)
1587
+ - At least one target channel selected (what to control)
1588
+ Without both, the schedule won't execute, so it's filtered as inactive.
1589
+
1590
+ """
1591
+ # Check critical fields needed for execution
1592
+ weekday = group_data.get(ScheduleField.WEEKDAY, [])
1593
+ target_channels = group_data.get(ScheduleField.TARGET_CHANNELS, [])
1594
+
1595
+ # Schedule is active only if both fields are non-empty
1596
+ return bool(weekday and target_channels)
1597
+
1598
+
1599
+ def create_empty_schedule_group(*, category: DataPointCategory | None = None) -> DEFAULT_SCHEDULE_GROUP:
1600
+ """
1601
+ Create an empty (deactivated) schedule group and tailor optional fields depending on the provided `category`.
1602
+
1603
+ Base (category‑agnostic) fields that are always included:
1604
+ - `ScheduleField.ASTRO_OFFSET` → `0`
1605
+ - `ScheduleField.ASTRO_TYPE` → `AstroType.SUNRISE`
1606
+ - `ScheduleField.CONDITION` → `ScheduleCondition.FIXED_TIME`
1607
+ - `ScheduleField.FIXED_HOUR` → `0`
1608
+ - `ScheduleField.FIXED_MINUTE` → `0`
1609
+ - `ScheduleField.TARGET_CHANNELS` → `[]` (empty list)
1610
+ - `ScheduleField.WEEKDAY` → `[]` (empty list)
1611
+
1612
+ Additional fields per `DataPointCategory`:
1613
+ - `DataPointCategory.COVER`:
1614
+ - `ScheduleField.LEVEL` → `0.0`
1615
+ - `ScheduleField.LEVEL_2` → `0.0`
1616
+
1617
+ - `DataPointCategory.SWITCH`:
1618
+ - `ScheduleField.DURATION_BASE` → `TimeBase.MS_100`
1619
+ - `ScheduleField.DURATION_FACTOR` → `0`
1620
+ - `ScheduleField.LEVEL` → `0` (binary level)
1621
+
1622
+ - `DataPointCategory.LIGHT`:
1623
+ - `ScheduleField.DURATION_BASE` → `TimeBase.MS_100`
1624
+ - `ScheduleField.DURATION_FACTOR` → `0`
1625
+ - `ScheduleField.RAMP_TIME_BASE` → `TimeBase.MS_100`
1626
+ - `ScheduleField.RAMP_TIME_FACTOR` → `0`
1627
+ - `ScheduleField.LEVEL` → `0.0`
1628
+
1629
+ - `DataPointCategory.VALVE`:
1630
+ - `ScheduleField.LEVEL` → `0.0`
1631
+
1632
+ Notes:
1633
+ - If `category` is `None` or not one of the above, only the base fields are
1634
+ included.
1635
+ - The created group is considered inactive by default (see
1636
+ `is_schedule_group_active`): it becomes active only after both
1637
+ `ScheduleField.WEEKDAY` and `ScheduleField.TARGET_CHANNELS` are non‑empty.
1638
+
1639
+ Returns:
1640
+ A schedule group dictionary with fields initialized to their inactive
1641
+ defaults according to the given `category`.
1642
+
1643
+ """
1644
+ empty_schedule_group = {
1645
+ ScheduleField.ASTRO_OFFSET: 0,
1646
+ ScheduleField.ASTRO_TYPE: AstroType.SUNRISE,
1647
+ ScheduleField.CONDITION: ScheduleCondition.FIXED_TIME,
1648
+ ScheduleField.FIXED_HOUR: 0,
1649
+ ScheduleField.FIXED_MINUTE: 0,
1650
+ ScheduleField.TARGET_CHANNELS: [],
1651
+ ScheduleField.WEEKDAY: [],
1652
+ }
1653
+ if category == DataPointCategory.COVER:
1654
+ empty_schedule_group.update(
1655
+ {
1656
+ ScheduleField.LEVEL: 0.0,
1657
+ ScheduleField.LEVEL_2: 0.0,
1658
+ }
1659
+ )
1660
+ if category == DataPointCategory.SWITCH:
1661
+ empty_schedule_group.update(
1662
+ {
1663
+ ScheduleField.DURATION_BASE: TimeBase.MS_100,
1664
+ ScheduleField.DURATION_FACTOR: 0,
1665
+ ScheduleField.LEVEL: 0,
1666
+ }
1667
+ )
1668
+ if category == DataPointCategory.LIGHT:
1669
+ empty_schedule_group.update(
1670
+ {
1671
+ ScheduleField.DURATION_BASE: TimeBase.MS_100,
1672
+ ScheduleField.DURATION_FACTOR: 0,
1673
+ ScheduleField.RAMP_TIME_BASE: TimeBase.MS_100,
1674
+ ScheduleField.RAMP_TIME_FACTOR: 0,
1675
+ ScheduleField.LEVEL: 0.0,
1676
+ }
1677
+ )
1678
+ if category == DataPointCategory.VALVE:
1679
+ empty_schedule_group.update(
1680
+ {
1681
+ ScheduleField.LEVEL: 0.0,
1682
+ }
1683
+ )
1684
+ return empty_schedule_group
1685
+
1686
+
1687
+ # climate
1688
+
1689
+
1690
+ def identify_base_temperature(*, weekday_data: ClimateWeekdaySchedule) -> float:
1691
+ """
1692
+ Identify base temperature from weekday data.
1693
+
1694
+ Identify the temperature that is used for the most minutes of a day.
1695
+ """
1696
+ if not weekday_data:
1697
+ return DEFAULT_CLIMATE_FILL_TEMPERATURE
1698
+
1699
+ # Track total minutes for each temperature
1700
+ temperature_minutes: dict[float, int] = {}
1701
+ previous_minutes = 0
1702
+
1703
+ # Iterate through slots in order
1704
+ for slot_no in sorted(weekday_data.keys()):
1705
+ slot = weekday_data[slot_no]
1706
+ endtime_minutes = _convert_time_str_to_minutes(time_str=str(slot["endtime"]))
1707
+ temperature = float(slot["temperature"])
1708
+
1709
+ # Calculate duration for this slot (from previous endtime to current endtime)
1710
+ duration = endtime_minutes - previous_minutes
1711
+
1712
+ # Add duration to the total for this temperature
1713
+ if temperature not in temperature_minutes:
1714
+ temperature_minutes[temperature] = 0
1715
+ temperature_minutes[temperature] += duration
1716
+
1717
+ previous_minutes = endtime_minutes
1718
+
1719
+ # Return the temperature with the most minutes
1720
+ if not temperature_minutes:
1721
+ return DEFAULT_CLIMATE_FILL_TEMPERATURE
1722
+
1723
+ return max(temperature_minutes, key=lambda temp: temperature_minutes[temp])
1724
+
1725
+
1726
+ def _convert_minutes_to_time_str(*, minutes: Any) -> str:
1727
+ """Convert minutes to a time string."""
1728
+ if not isinstance(minutes, int):
1729
+ return CLIMATE_MAX_SCHEDULER_TIME
1730
+ time_str = f"{minutes // 60:0=2}:{minutes % 60:0=2}"
1731
+ if SCHEDULER_TIME_PATTERN.match(time_str) is None:
1732
+ raise ValidationException(
1733
+ i18n.tr(
1734
+ key="exception.model.week_profile.validate.time_invalid_format",
1735
+ time=time_str,
1736
+ min=CLIMATE_MIN_SCHEDULER_TIME,
1737
+ max=CLIMATE_MAX_SCHEDULER_TIME,
1738
+ )
1739
+ )
1740
+ return time_str
1741
+
1742
+
1743
+ def _convert_time_str_to_minutes(*, time_str: str) -> int:
1744
+ """Convert minutes to a time string."""
1745
+ if SCHEDULER_TIME_PATTERN.match(time_str) is None:
1746
+ raise ValidationException(
1747
+ i18n.tr(
1748
+ key="exception.model.week_profile.validate.time_invalid_format",
1749
+ time=time_str,
1750
+ min=CLIMATE_MIN_SCHEDULER_TIME,
1751
+ max=CLIMATE_MAX_SCHEDULER_TIME,
1752
+ )
1753
+ )
1754
+ try:
1755
+ h, m = time_str.split(":")
1756
+ return (int(h) * 60) + int(m)
1757
+ except Exception as exc:
1758
+ raise ValidationException(
1759
+ i18n.tr(
1760
+ key="exception.model.week_profile.validate.time_convert_failed",
1761
+ time=time_str,
1762
+ )
1763
+ ) from exc
1764
+
1765
+
1766
+ def _fillup_weekday_data(*, base_temperature: float, weekday_data: ClimateWeekdaySchedule) -> ClimateWeekdaySchedule:
1767
+ """Fillup weekday data."""
1768
+ for slot_no in CLIMATE_SCHEDULE_SLOT_IN_RANGE:
1769
+ if slot_no not in weekday_data:
1770
+ weekday_data[slot_no] = {
1771
+ "endtime": CLIMATE_MAX_SCHEDULER_TIME,
1772
+ "temperature": base_temperature,
1773
+ }
1774
+
1775
+ return weekday_data
1776
+
1777
+
1778
+ def _normalize_weekday_data(*, weekday_data: ClimateWeekdaySchedule | dict[str, Any]) -> ClimateWeekdaySchedule:
1779
+ """
1780
+ Normalize climate weekday schedule data.
1781
+
1782
+ Ensures slot keys are integers (not strings) and slots are sorted chronologically
1783
+ by ENDTIME. Re-indexes slots from 1-13 in temporal order. Fills missing slots
1784
+ at the end with 24:00 entries.
1785
+
1786
+ Args:
1787
+ weekday_data: Weekday schedule data (possibly with string keys)
1788
+
1789
+ Returns:
1790
+ Normalized weekday schedule with integer keys 1-13 sorted by time
1791
+
1792
+ Example:
1793
+ Input: {"2": {ENDTIME: "12:00"}, "1": {ENDTIME: "06:00"}}
1794
+ Output: {1: {ENDTIME: "06:00"}, 2: {ENDTIME: "12:00"}, 3: {ENDTIME: "24:00", TEMPERATURE: ...}, ...}
1795
+
1796
+ """
1797
+ # Convert string keys to int if necessary
1798
+ normalized_data: ClimateWeekdaySchedule = {}
1799
+ for key, value in weekday_data.items():
1800
+ int_key = int(key) if isinstance(key, str) else key
1801
+ normalized_data[int_key] = value
1802
+
1803
+ # Sort by ENDTIME and reassign slot numbers 1-13
1804
+ sorted_slots = sorted(
1805
+ normalized_data.items(),
1806
+ key=lambda item: _convert_time_str_to_minutes(time_str=str(item[1]["endtime"])),
1807
+ )
1808
+
1809
+ # Reassign slot numbers from 1 to N (where N is number of existing slots)
1810
+ result: ClimateWeekdaySchedule = {}
1811
+ for new_slot_no, (_, slot_data) in enumerate(sorted_slots, start=1):
1812
+ result[new_slot_no] = slot_data
1813
+
1814
+ # Fill up missing slots (from N+1 to 13) with 24:00 entries
1815
+ if result:
1816
+ # Get the temperature from the last existing slot
1817
+ last_slot = result[len(result)]
1818
+ fill_temperature = last_slot.get("temperature", DEFAULT_CLIMATE_FILL_TEMPERATURE)
1819
+
1820
+ # Fill missing slots
1821
+ for slot_no in range(len(result) + 1, 14):
1822
+ result[slot_no] = {
1823
+ "endtime": CLIMATE_MAX_SCHEDULER_TIME,
1824
+ "temperature": fill_temperature,
1825
+ }
1826
+
1827
+ return result