aiohomematic 2026.1.29__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aiohomematic/__init__.py +110 -0
- aiohomematic/_log_context_protocol.py +29 -0
- aiohomematic/api.py +410 -0
- aiohomematic/async_support.py +250 -0
- aiohomematic/backend_detection.py +462 -0
- aiohomematic/central/__init__.py +103 -0
- aiohomematic/central/async_rpc_server.py +760 -0
- aiohomematic/central/central_unit.py +1152 -0
- aiohomematic/central/config.py +463 -0
- aiohomematic/central/config_builder.py +772 -0
- aiohomematic/central/connection_state.py +160 -0
- aiohomematic/central/coordinators/__init__.py +38 -0
- aiohomematic/central/coordinators/cache.py +414 -0
- aiohomematic/central/coordinators/client.py +480 -0
- aiohomematic/central/coordinators/connection_recovery.py +1141 -0
- aiohomematic/central/coordinators/device.py +1166 -0
- aiohomematic/central/coordinators/event.py +514 -0
- aiohomematic/central/coordinators/hub.py +532 -0
- aiohomematic/central/decorators.py +184 -0
- aiohomematic/central/device_registry.py +229 -0
- aiohomematic/central/events/__init__.py +104 -0
- aiohomematic/central/events/bus.py +1392 -0
- aiohomematic/central/events/integration.py +424 -0
- aiohomematic/central/events/types.py +194 -0
- aiohomematic/central/health.py +762 -0
- aiohomematic/central/rpc_server.py +353 -0
- aiohomematic/central/scheduler.py +794 -0
- aiohomematic/central/state_machine.py +391 -0
- aiohomematic/client/__init__.py +203 -0
- aiohomematic/client/_rpc_errors.py +187 -0
- aiohomematic/client/backends/__init__.py +48 -0
- aiohomematic/client/backends/base.py +335 -0
- aiohomematic/client/backends/capabilities.py +138 -0
- aiohomematic/client/backends/ccu.py +487 -0
- aiohomematic/client/backends/factory.py +116 -0
- aiohomematic/client/backends/homegear.py +294 -0
- aiohomematic/client/backends/json_ccu.py +252 -0
- aiohomematic/client/backends/protocol.py +316 -0
- aiohomematic/client/ccu.py +1857 -0
- aiohomematic/client/circuit_breaker.py +459 -0
- aiohomematic/client/config.py +64 -0
- aiohomematic/client/handlers/__init__.py +40 -0
- aiohomematic/client/handlers/backup.py +157 -0
- aiohomematic/client/handlers/base.py +79 -0
- aiohomematic/client/handlers/device_ops.py +1085 -0
- aiohomematic/client/handlers/firmware.py +144 -0
- aiohomematic/client/handlers/link_mgmt.py +199 -0
- aiohomematic/client/handlers/metadata.py +436 -0
- aiohomematic/client/handlers/programs.py +144 -0
- aiohomematic/client/handlers/sysvars.py +100 -0
- aiohomematic/client/interface_client.py +1304 -0
- aiohomematic/client/json_rpc.py +2068 -0
- aiohomematic/client/request_coalescer.py +282 -0
- aiohomematic/client/rpc_proxy.py +629 -0
- aiohomematic/client/state_machine.py +324 -0
- aiohomematic/const.py +2207 -0
- aiohomematic/context.py +275 -0
- aiohomematic/converter.py +270 -0
- aiohomematic/decorators.py +390 -0
- aiohomematic/exceptions.py +185 -0
- aiohomematic/hmcli.py +997 -0
- aiohomematic/i18n.py +193 -0
- aiohomematic/interfaces/__init__.py +407 -0
- aiohomematic/interfaces/central.py +1067 -0
- aiohomematic/interfaces/client.py +1096 -0
- aiohomematic/interfaces/coordinators.py +63 -0
- aiohomematic/interfaces/model.py +1921 -0
- aiohomematic/interfaces/operations.py +217 -0
- aiohomematic/logging_context.py +134 -0
- aiohomematic/metrics/__init__.py +125 -0
- aiohomematic/metrics/_protocols.py +140 -0
- aiohomematic/metrics/aggregator.py +534 -0
- aiohomematic/metrics/dataclasses.py +489 -0
- aiohomematic/metrics/emitter.py +292 -0
- aiohomematic/metrics/events.py +183 -0
- aiohomematic/metrics/keys.py +300 -0
- aiohomematic/metrics/observer.py +563 -0
- aiohomematic/metrics/stats.py +172 -0
- aiohomematic/model/__init__.py +189 -0
- aiohomematic/model/availability.py +65 -0
- aiohomematic/model/calculated/__init__.py +89 -0
- aiohomematic/model/calculated/climate.py +276 -0
- aiohomematic/model/calculated/data_point.py +315 -0
- aiohomematic/model/calculated/field.py +147 -0
- aiohomematic/model/calculated/operating_voltage_level.py +286 -0
- aiohomematic/model/calculated/support.py +232 -0
- aiohomematic/model/custom/__init__.py +214 -0
- aiohomematic/model/custom/capabilities/__init__.py +67 -0
- aiohomematic/model/custom/capabilities/climate.py +41 -0
- aiohomematic/model/custom/capabilities/light.py +87 -0
- aiohomematic/model/custom/capabilities/lock.py +44 -0
- aiohomematic/model/custom/capabilities/siren.py +63 -0
- aiohomematic/model/custom/climate.py +1130 -0
- aiohomematic/model/custom/cover.py +722 -0
- aiohomematic/model/custom/data_point.py +360 -0
- aiohomematic/model/custom/definition.py +300 -0
- aiohomematic/model/custom/field.py +89 -0
- aiohomematic/model/custom/light.py +1174 -0
- aiohomematic/model/custom/lock.py +322 -0
- aiohomematic/model/custom/mixins.py +445 -0
- aiohomematic/model/custom/profile.py +945 -0
- aiohomematic/model/custom/registry.py +251 -0
- aiohomematic/model/custom/siren.py +462 -0
- aiohomematic/model/custom/switch.py +195 -0
- aiohomematic/model/custom/text_display.py +289 -0
- aiohomematic/model/custom/valve.py +78 -0
- aiohomematic/model/data_point.py +1416 -0
- aiohomematic/model/device.py +1840 -0
- aiohomematic/model/event.py +216 -0
- aiohomematic/model/generic/__init__.py +327 -0
- aiohomematic/model/generic/action.py +40 -0
- aiohomematic/model/generic/action_select.py +62 -0
- aiohomematic/model/generic/binary_sensor.py +30 -0
- aiohomematic/model/generic/button.py +31 -0
- aiohomematic/model/generic/data_point.py +177 -0
- aiohomematic/model/generic/dummy.py +150 -0
- aiohomematic/model/generic/number.py +76 -0
- aiohomematic/model/generic/select.py +56 -0
- aiohomematic/model/generic/sensor.py +76 -0
- aiohomematic/model/generic/switch.py +54 -0
- aiohomematic/model/generic/text.py +33 -0
- aiohomematic/model/hub/__init__.py +100 -0
- aiohomematic/model/hub/binary_sensor.py +24 -0
- aiohomematic/model/hub/button.py +28 -0
- aiohomematic/model/hub/connectivity.py +190 -0
- aiohomematic/model/hub/data_point.py +342 -0
- aiohomematic/model/hub/hub.py +864 -0
- aiohomematic/model/hub/inbox.py +135 -0
- aiohomematic/model/hub/install_mode.py +393 -0
- aiohomematic/model/hub/metrics.py +208 -0
- aiohomematic/model/hub/number.py +42 -0
- aiohomematic/model/hub/select.py +52 -0
- aiohomematic/model/hub/sensor.py +37 -0
- aiohomematic/model/hub/switch.py +43 -0
- aiohomematic/model/hub/text.py +30 -0
- aiohomematic/model/hub/update.py +221 -0
- aiohomematic/model/support.py +592 -0
- aiohomematic/model/update.py +140 -0
- aiohomematic/model/week_profile.py +1827 -0
- aiohomematic/property_decorators.py +719 -0
- aiohomematic/py.typed +0 -0
- aiohomematic/rega_scripts/accept_device_in_inbox.fn +51 -0
- aiohomematic/rega_scripts/create_backup_start.fn +28 -0
- aiohomematic/rega_scripts/create_backup_status.fn +89 -0
- aiohomematic/rega_scripts/fetch_all_device_data.fn +97 -0
- aiohomematic/rega_scripts/get_backend_info.fn +25 -0
- aiohomematic/rega_scripts/get_inbox_devices.fn +61 -0
- aiohomematic/rega_scripts/get_program_descriptions.fn +31 -0
- aiohomematic/rega_scripts/get_serial.fn +44 -0
- aiohomematic/rega_scripts/get_service_messages.fn +83 -0
- aiohomematic/rega_scripts/get_system_update_info.fn +39 -0
- aiohomematic/rega_scripts/get_system_variable_descriptions.fn +31 -0
- aiohomematic/rega_scripts/set_program_state.fn +17 -0
- aiohomematic/rega_scripts/set_system_variable.fn +19 -0
- aiohomematic/rega_scripts/trigger_firmware_update.fn +67 -0
- aiohomematic/schemas.py +256 -0
- aiohomematic/store/__init__.py +55 -0
- aiohomematic/store/dynamic/__init__.py +43 -0
- aiohomematic/store/dynamic/command.py +250 -0
- aiohomematic/store/dynamic/data.py +175 -0
- aiohomematic/store/dynamic/details.py +187 -0
- aiohomematic/store/dynamic/ping_pong.py +416 -0
- aiohomematic/store/persistent/__init__.py +71 -0
- aiohomematic/store/persistent/base.py +285 -0
- aiohomematic/store/persistent/device.py +233 -0
- aiohomematic/store/persistent/incident.py +380 -0
- aiohomematic/store/persistent/paramset.py +241 -0
- aiohomematic/store/persistent/session.py +556 -0
- aiohomematic/store/serialization.py +150 -0
- aiohomematic/store/storage.py +689 -0
- aiohomematic/store/types.py +526 -0
- aiohomematic/store/visibility/__init__.py +40 -0
- aiohomematic/store/visibility/parser.py +141 -0
- aiohomematic/store/visibility/registry.py +722 -0
- aiohomematic/store/visibility/rules.py +307 -0
- aiohomematic/strings.json +237 -0
- aiohomematic/support.py +706 -0
- aiohomematic/tracing.py +236 -0
- aiohomematic/translations/de.json +237 -0
- aiohomematic/translations/en.json +237 -0
- aiohomematic/type_aliases.py +51 -0
- aiohomematic/validator.py +128 -0
- aiohomematic-2026.1.29.dist-info/METADATA +296 -0
- aiohomematic-2026.1.29.dist-info/RECORD +188 -0
- aiohomematic-2026.1.29.dist-info/WHEEL +5 -0
- aiohomematic-2026.1.29.dist-info/entry_points.txt +2 -0
- aiohomematic-2026.1.29.dist-info/licenses/LICENSE +21 -0
- aiohomematic-2026.1.29.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|