python-bsblan 6.1.4__py3-none-any.whl → 6.1.6__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.
- bsblan/_hot_water.py +249 -0
- bsblan/_parameters.py +155 -0
- bsblan/_schedules.py +148 -0
- bsblan/_thermostat.py +137 -0
- bsblan/_transport.py +29 -1
- bsblan/bsblan.py +68 -334
- bsblan/constants.py +7 -0
- bsblan/models.py +3 -0
- bsblan/utility.py +19 -1
- {python_bsblan-6.1.4.dist-info → python_bsblan-6.1.6.dist-info}/METADATA +2 -2
- python_bsblan-6.1.6.dist-info/RECORD +19 -0
- python_bsblan-6.1.4.dist-info/RECORD +0 -15
- {python_bsblan-6.1.4.dist-info → python_bsblan-6.1.6.dist-info}/WHEEL +0 -0
- {python_bsblan-6.1.4.dist-info → python_bsblan-6.1.6.dist-info}/licenses/LICENSE.md +0 -0
bsblan/_hot_water.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Hot water domain logic for BSB-LAN."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any, TypeVar
|
|
6
|
+
|
|
7
|
+
from .constants import ErrorMsg, HotWaterParams
|
|
8
|
+
from .exceptions import BSBLANError
|
|
9
|
+
from .models import (
|
|
10
|
+
HotWaterConfig,
|
|
11
|
+
HotWaterSchedule,
|
|
12
|
+
HotWaterState,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Awaitable, Callable
|
|
17
|
+
|
|
18
|
+
from .models import SetHotWaterParam
|
|
19
|
+
|
|
20
|
+
# TypeVar for hot water data models
|
|
21
|
+
HotWaterDataT = TypeVar(
|
|
22
|
+
"HotWaterDataT", HotWaterState, HotWaterConfig, HotWaterSchedule
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class HotWaterManager:
|
|
27
|
+
"""Read and write hot water state, configuration, and schedules."""
|
|
28
|
+
|
|
29
|
+
def __init__( # noqa: PLR0913 # pylint: disable=too-many-arguments
|
|
30
|
+
self,
|
|
31
|
+
*,
|
|
32
|
+
ensure_group_validated: Callable[
|
|
33
|
+
[str, set[str], list[str] | None], Awaitable[None]
|
|
34
|
+
],
|
|
35
|
+
get_param_cache: Callable[[], dict[str, str]],
|
|
36
|
+
apply_include_filter: Callable[
|
|
37
|
+
[dict[str, str], list[str] | None], dict[str, str]
|
|
38
|
+
],
|
|
39
|
+
request_named_params: Callable[[dict[str, str]], Awaitable[dict[str, Any]]],
|
|
40
|
+
validate_single_parameter: Callable[..., None],
|
|
41
|
+
set_payload: Callable[[str, str], dict[str, Any]],
|
|
42
|
+
set_device_state: Callable[[dict[str, Any]], Awaitable[None]],
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Initialize the hot water manager.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
ensure_group_validated: Validates a hot water param group lazily.
|
|
48
|
+
get_param_cache: Returns the validated hot water param cache.
|
|
49
|
+
apply_include_filter: Filters params by requested names.
|
|
50
|
+
request_named_params: Requests params and maps them to names.
|
|
51
|
+
validate_single_parameter: Ensures only one parameter is set.
|
|
52
|
+
set_payload: Builds a set-parameter payload.
|
|
53
|
+
set_device_state: Sends a set-parameter request to the device.
|
|
54
|
+
|
|
55
|
+
"""
|
|
56
|
+
self._ensure_group_validated = ensure_group_validated
|
|
57
|
+
self._get_param_cache = get_param_cache
|
|
58
|
+
self._apply_include_filter = apply_include_filter
|
|
59
|
+
self._request_named_params = request_named_params
|
|
60
|
+
self._validate_single_parameter = validate_single_parameter
|
|
61
|
+
self._set_payload = set_payload
|
|
62
|
+
self._set_device_state = set_device_state
|
|
63
|
+
|
|
64
|
+
async def fetch_data(
|
|
65
|
+
self,
|
|
66
|
+
param_filter: set[str],
|
|
67
|
+
model_class: type[HotWaterDataT],
|
|
68
|
+
error_msg: str,
|
|
69
|
+
group_name: str,
|
|
70
|
+
include: list[str] | None = None,
|
|
71
|
+
) -> HotWaterDataT:
|
|
72
|
+
"""Fetch hot water data for a specific parameter set.
|
|
73
|
+
|
|
74
|
+
This is a generic helper method that fetches hot water parameters
|
|
75
|
+
based on the provided filter and returns the appropriate model.
|
|
76
|
+
It uses granular lazy loading to validate only the specific param group.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
param_filter: Set of parameter IDs to fetch.
|
|
80
|
+
model_class: The dataclass type to deserialize the response into.
|
|
81
|
+
error_msg: Error message if no parameters are available.
|
|
82
|
+
group_name: Name of the param group for lazy validation tracking.
|
|
83
|
+
include: Optional list of parameter names to fetch. If None,
|
|
84
|
+
fetches all parameters in the group.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
The populated model instance.
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
BSBLANError: If no parameters are available for the filter.
|
|
91
|
+
|
|
92
|
+
"""
|
|
93
|
+
# Granular lazy load: validate only this param group on first access
|
|
94
|
+
# Pass include filter so we only validate requested params
|
|
95
|
+
await self._ensure_group_validated(group_name, param_filter, include)
|
|
96
|
+
|
|
97
|
+
# Use cached validated params
|
|
98
|
+
filtered_params = {
|
|
99
|
+
param_id: param_name
|
|
100
|
+
for param_id, param_name in self._get_param_cache().items()
|
|
101
|
+
if param_id in param_filter
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# Apply include filter if specified
|
|
105
|
+
filtered_params = self._apply_include_filter(filtered_params, include)
|
|
106
|
+
|
|
107
|
+
if not filtered_params:
|
|
108
|
+
raise BSBLANError(error_msg)
|
|
109
|
+
|
|
110
|
+
data = await self._request_named_params(filtered_params)
|
|
111
|
+
return model_class.model_validate(data)
|
|
112
|
+
|
|
113
|
+
async def state(self, include: list[str] | None = None) -> HotWaterState:
|
|
114
|
+
"""Get essential hot water state for frequent polling.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
include: Optional list of parameter names to fetch.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
HotWaterState: Essential hot water state information.
|
|
121
|
+
|
|
122
|
+
"""
|
|
123
|
+
return await self.fetch_data(
|
|
124
|
+
param_filter=HotWaterParams.ESSENTIAL,
|
|
125
|
+
model_class=HotWaterState,
|
|
126
|
+
error_msg="No essential hot water parameters available",
|
|
127
|
+
group_name="essential",
|
|
128
|
+
include=include,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
async def config(self, include: list[str] | None = None) -> HotWaterConfig:
|
|
132
|
+
"""Get hot water configuration and advanced settings.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
include: Optional list of parameter names to fetch.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
HotWaterConfig: Hot water configuration information.
|
|
139
|
+
|
|
140
|
+
"""
|
|
141
|
+
return await self.fetch_data(
|
|
142
|
+
param_filter=HotWaterParams.CONFIG,
|
|
143
|
+
model_class=HotWaterConfig,
|
|
144
|
+
error_msg="No hot water configuration parameters available",
|
|
145
|
+
group_name="config",
|
|
146
|
+
include=include,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
async def schedule(self, include: list[str] | None = None) -> HotWaterSchedule:
|
|
150
|
+
"""Get hot water time program schedules.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
include: Optional list of parameter names to fetch.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
HotWaterSchedule: Hot water schedule information.
|
|
157
|
+
|
|
158
|
+
"""
|
|
159
|
+
return await self.fetch_data(
|
|
160
|
+
param_filter=HotWaterParams.SCHEDULE,
|
|
161
|
+
model_class=HotWaterSchedule,
|
|
162
|
+
error_msg="No hot water schedule parameters available",
|
|
163
|
+
group_name="schedule",
|
|
164
|
+
include=include,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
async def set_hot_water(self, params: SetHotWaterParam) -> None:
|
|
168
|
+
"""Change the state of the hot water system through BSB-Lan.
|
|
169
|
+
|
|
170
|
+
Only one parameter should be set at a time (BSB-LAN API limitation).
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
params: SetHotWaterParam object containing the parameter to set.
|
|
174
|
+
|
|
175
|
+
Raises:
|
|
176
|
+
BSBLANError: If multiple parameters are set or no parameter is set.
|
|
177
|
+
|
|
178
|
+
"""
|
|
179
|
+
# Validate only one parameter is being set
|
|
180
|
+
time_program_params: list[str] = []
|
|
181
|
+
if params.dhw_time_programs:
|
|
182
|
+
programs = params.dhw_time_programs
|
|
183
|
+
time_program_params.extend(
|
|
184
|
+
prog
|
|
185
|
+
for prog in [
|
|
186
|
+
programs.monday,
|
|
187
|
+
programs.tuesday,
|
|
188
|
+
programs.wednesday,
|
|
189
|
+
programs.thursday,
|
|
190
|
+
programs.friday,
|
|
191
|
+
programs.saturday,
|
|
192
|
+
programs.sunday,
|
|
193
|
+
programs.standard_values,
|
|
194
|
+
]
|
|
195
|
+
if prog
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
self._validate_single_parameter(
|
|
199
|
+
params.nominal_setpoint,
|
|
200
|
+
params.reduced_setpoint,
|
|
201
|
+
params.nominal_setpoint_max,
|
|
202
|
+
params.operating_mode,
|
|
203
|
+
params.eco_mode_selection,
|
|
204
|
+
params.dhw_charging_priority,
|
|
205
|
+
params.legionella_function_setpoint,
|
|
206
|
+
params.legionella_function_periodicity,
|
|
207
|
+
params.legionella_function_day,
|
|
208
|
+
params.legionella_function_time,
|
|
209
|
+
params.legionella_function_dwelling_time,
|
|
210
|
+
params.operating_mode_changeover,
|
|
211
|
+
*time_program_params,
|
|
212
|
+
error_msg=ErrorMsg.MULTI_PARAMETER,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
state = self.prepare_state(params)
|
|
216
|
+
await self._set_device_state(state)
|
|
217
|
+
|
|
218
|
+
def prepare_state(self, params: SetHotWaterParam) -> dict[str, Any]:
|
|
219
|
+
"""Prepare the hot water state for setting.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
params: SetHotWaterParam object containing the parameter to set.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
dict[str, Any]: The prepared state for the hot water.
|
|
226
|
+
|
|
227
|
+
Raises:
|
|
228
|
+
BSBLANError: If no state is provided.
|
|
229
|
+
|
|
230
|
+
"""
|
|
231
|
+
state: dict[str, Any] = {}
|
|
232
|
+
|
|
233
|
+
# Process all mapped parameters using constants
|
|
234
|
+
for param_id, attr_name in HotWaterParams.SETTABLE.items():
|
|
235
|
+
value = getattr(params, attr_name)
|
|
236
|
+
if value is not None:
|
|
237
|
+
state.update(self._set_payload(param_id, str(value)))
|
|
238
|
+
|
|
239
|
+
# Process time programs if provided using constants
|
|
240
|
+
if params.dhw_time_programs:
|
|
241
|
+
for param_id, attr_name in HotWaterParams.TIME_PROGRAMS.items():
|
|
242
|
+
value = getattr(params.dhw_time_programs, attr_name)
|
|
243
|
+
if value is not None:
|
|
244
|
+
state.update(self._set_payload(param_id, value))
|
|
245
|
+
|
|
246
|
+
if not state:
|
|
247
|
+
raise BSBLANError(ErrorMsg.NO_STATE)
|
|
248
|
+
|
|
249
|
+
return state
|
bsblan/_parameters.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Low-level parameter lookup and read helpers for BSB-LAN."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
6
|
+
|
|
7
|
+
from .constants import ErrorMsg
|
|
8
|
+
from .exceptions import BSBLANError
|
|
9
|
+
from .models import EntityInfo
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Awaitable, Callable
|
|
13
|
+
|
|
14
|
+
from .constants import APIConfig
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ParameterReader:
|
|
18
|
+
"""Resolve and read BSB-LAN parameters by ID or name."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
request: Callable[..., Awaitable[dict[str, Any]]],
|
|
24
|
+
get_api_data: Callable[[], APIConfig | None],
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Initialize the parameter reader.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
request: Callable performing a client request.
|
|
30
|
+
get_api_data: Callable returning the current API config data.
|
|
31
|
+
|
|
32
|
+
"""
|
|
33
|
+
self._request = request
|
|
34
|
+
self._get_api_data = get_api_data
|
|
35
|
+
|
|
36
|
+
async def read_parameters(
|
|
37
|
+
self,
|
|
38
|
+
parameter_ids: list[str],
|
|
39
|
+
) -> dict[str, EntityInfo]:
|
|
40
|
+
"""Read specific parameters by their BSB-LAN parameter IDs.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
parameter_ids: List of BSB-LAN parameter IDs to fetch.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
dict[str, EntityInfo]: Dictionary mapping parameter IDs to
|
|
47
|
+
EntityInfo objects.
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
BSBLANError: If no parameter IDs are provided or request fails.
|
|
51
|
+
|
|
52
|
+
"""
|
|
53
|
+
if not parameter_ids:
|
|
54
|
+
raise BSBLANError(ErrorMsg.NO_PARAMETER_IDS)
|
|
55
|
+
|
|
56
|
+
# Request the parameters from the device
|
|
57
|
+
params_string = ",".join(parameter_ids)
|
|
58
|
+
response_data = await self._request(params={"Parameter": params_string})
|
|
59
|
+
|
|
60
|
+
# Convert response to EntityInfo objects
|
|
61
|
+
result: dict[str, EntityInfo] = {}
|
|
62
|
+
for param_id in parameter_ids:
|
|
63
|
+
if param_id in response_data:
|
|
64
|
+
param_data = response_data[param_id]
|
|
65
|
+
if param_data and isinstance(param_data, dict):
|
|
66
|
+
result[param_id] = EntityInfo.model_validate(param_data)
|
|
67
|
+
|
|
68
|
+
return result
|
|
69
|
+
|
|
70
|
+
def get_parameter_id(self, parameter_name: str) -> str | None:
|
|
71
|
+
"""Look up the parameter ID for a given parameter name.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
parameter_name: The parameter name (e.g., "current_temperature").
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
str | None: The parameter ID if found, None otherwise.
|
|
78
|
+
|
|
79
|
+
"""
|
|
80
|
+
api_data = self._get_api_data()
|
|
81
|
+
if not api_data:
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
# Search through all sections for the parameter name
|
|
85
|
+
for section_params in api_data.values():
|
|
86
|
+
section_dict = cast("dict[str, str]", section_params)
|
|
87
|
+
for param_id, param_name in section_dict.items():
|
|
88
|
+
if param_name == parameter_name:
|
|
89
|
+
return param_id
|
|
90
|
+
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
def get_parameter_ids(self, parameter_names: list[str]) -> dict[str, str]:
|
|
94
|
+
"""Look up parameter IDs for multiple parameter names.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
parameter_names: List of parameter names to look up.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
dict[str, str]: Dictionary mapping parameter names to their IDs.
|
|
101
|
+
Only includes parameters that were found.
|
|
102
|
+
|
|
103
|
+
"""
|
|
104
|
+
result: dict[str, str] = {}
|
|
105
|
+
for name in parameter_names:
|
|
106
|
+
param_id = self.get_parameter_id(name)
|
|
107
|
+
if param_id is not None:
|
|
108
|
+
result[name] = param_id
|
|
109
|
+
return result
|
|
110
|
+
|
|
111
|
+
async def read_parameters_by_name(
|
|
112
|
+
self,
|
|
113
|
+
parameter_names: list[str],
|
|
114
|
+
) -> dict[str, EntityInfo]:
|
|
115
|
+
"""Read specific parameters by their names.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
parameter_names: List of parameter names to fetch.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
dict[str, EntityInfo]: Dictionary mapping parameter names to
|
|
122
|
+
EntityInfo objects. Only includes parameters that were found
|
|
123
|
+
and had valid data.
|
|
124
|
+
|
|
125
|
+
Raises:
|
|
126
|
+
BSBLANError: If no parameter names are provided, no IDs could be
|
|
127
|
+
resolved, or the client is not initialized.
|
|
128
|
+
|
|
129
|
+
"""
|
|
130
|
+
if not parameter_names:
|
|
131
|
+
raise BSBLANError(ErrorMsg.NO_PARAMETER_NAMES)
|
|
132
|
+
|
|
133
|
+
if not self._get_api_data():
|
|
134
|
+
raise BSBLANError(ErrorMsg.API_DATA_NOT_INITIALIZED)
|
|
135
|
+
|
|
136
|
+
# Resolve names to IDs
|
|
137
|
+
name_to_id = self.get_parameter_ids(parameter_names)
|
|
138
|
+
|
|
139
|
+
if not name_to_id:
|
|
140
|
+
unknown_params = ", ".join(parameter_names)
|
|
141
|
+
msg = f"{ErrorMsg.PARAMETER_NAMES_NOT_RESOLVED}: {unknown_params}"
|
|
142
|
+
raise BSBLANError(msg)
|
|
143
|
+
|
|
144
|
+
# Fetch parameters by ID
|
|
145
|
+
param_ids = list(name_to_id.values())
|
|
146
|
+
id_results = await self.read_parameters(param_ids)
|
|
147
|
+
|
|
148
|
+
# Convert back to name-keyed dictionary
|
|
149
|
+
# id_to_name maps param_id -> param_name for requested params only
|
|
150
|
+
id_to_name = {v: k for k, v in name_to_id.items()}
|
|
151
|
+
return {
|
|
152
|
+
id_to_name[param_id]: entity_info
|
|
153
|
+
for param_id, entity_info in id_results.items()
|
|
154
|
+
if param_id in id_to_name
|
|
155
|
+
}
|
bsblan/_schedules.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Heating and hot water schedule domain logic for BSB-LAN."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from .constants import ErrorMsg, HeatingScheduleParams, HotWaterParams
|
|
8
|
+
from .exceptions import BSBLANError
|
|
9
|
+
from .models import HeatingTimeSwitchPrograms
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Awaitable, Callable
|
|
13
|
+
|
|
14
|
+
from .models import DaySchedule, DHWSchedule, HeatingSchedule
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ScheduleManager:
|
|
18
|
+
"""Read and write heating and hot water time switch programs."""
|
|
19
|
+
|
|
20
|
+
def __init__( # noqa: PLR0913 # pylint: disable=too-many-arguments
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
request: Callable[..., Awaitable[dict[str, Any]]],
|
|
24
|
+
extract_params_summary: Callable[[dict[Any, Any]], dict[Any, Any]],
|
|
25
|
+
apply_include_filter: Callable[
|
|
26
|
+
[dict[str, str], list[str] | None], dict[str, str]
|
|
27
|
+
],
|
|
28
|
+
validate_circuit: Callable[[int], None],
|
|
29
|
+
set_payload: Callable[[str, str], dict[str, Any]],
|
|
30
|
+
set_device_state: Callable[[dict[str, Any]], Awaitable[None]],
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Initialize the schedule manager.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
request: Callable performing a client request.
|
|
36
|
+
extract_params_summary: Builds the request parameter summary.
|
|
37
|
+
apply_include_filter: Filters params by requested names.
|
|
38
|
+
validate_circuit: Validates a heating circuit number.
|
|
39
|
+
set_payload: Builds a set-parameter payload.
|
|
40
|
+
set_device_state: Sends a set-parameter request to the device.
|
|
41
|
+
|
|
42
|
+
"""
|
|
43
|
+
self._request = request
|
|
44
|
+
self._extract_params_summary = extract_params_summary
|
|
45
|
+
self._apply_include_filter = apply_include_filter
|
|
46
|
+
self._validate_circuit = validate_circuit
|
|
47
|
+
self._set_payload = set_payload
|
|
48
|
+
self._set_device_state = set_device_state
|
|
49
|
+
|
|
50
|
+
async def heating_schedule(
|
|
51
|
+
self,
|
|
52
|
+
include: list[str] | None = None,
|
|
53
|
+
circuit: int = 1,
|
|
54
|
+
) -> HeatingTimeSwitchPrograms:
|
|
55
|
+
"""Get heating time switch programs for a specific circuit.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
include: Optional list of day names to fetch.
|
|
59
|
+
circuit: The heating circuit number (1 or 2). Defaults to 1.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
HeatingTimeSwitchPrograms: Heating schedule information.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
BSBLANError: If no schedule parameters are available.
|
|
66
|
+
|
|
67
|
+
"""
|
|
68
|
+
self._validate_circuit(circuit)
|
|
69
|
+
time_program_params = HeatingScheduleParams.TIME_PROGRAMS[circuit]
|
|
70
|
+
|
|
71
|
+
filtered_params = self._apply_include_filter(time_program_params, include)
|
|
72
|
+
|
|
73
|
+
params = self._extract_params_summary(filtered_params)
|
|
74
|
+
data = await self._request(params={"Parameter": params["string_par"]})
|
|
75
|
+
mapped_data = {
|
|
76
|
+
name: data[param_id]
|
|
77
|
+
for param_id, name in filtered_params.items()
|
|
78
|
+
if param_id in data
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if not mapped_data:
|
|
82
|
+
raise BSBLANError(ErrorMsg.NO_HEATING_SCHEDULE_PARAMS)
|
|
83
|
+
|
|
84
|
+
return HeatingTimeSwitchPrograms.model_validate(mapped_data)
|
|
85
|
+
|
|
86
|
+
async def set_heating_schedule(
|
|
87
|
+
self,
|
|
88
|
+
schedule: HeatingSchedule,
|
|
89
|
+
circuit: int = 1,
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Set heating time switch programs for a specific circuit.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
schedule: HeatingSchedule object containing the weekly schedule.
|
|
95
|
+
circuit: The heating circuit number (1 or 2). Defaults to 1.
|
|
96
|
+
|
|
97
|
+
Raises:
|
|
98
|
+
BSBLANError: If no schedule is provided.
|
|
99
|
+
|
|
100
|
+
"""
|
|
101
|
+
self._validate_circuit(circuit)
|
|
102
|
+
await self._write_day_schedules(
|
|
103
|
+
schedule,
|
|
104
|
+
HeatingScheduleParams.TIME_PROGRAMS[circuit],
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
async def set_hot_water_schedule(self, schedule: DHWSchedule) -> None:
|
|
108
|
+
"""Set hot water time program schedules.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
schedule: DHWSchedule object containing the weekly schedule.
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
BSBLANError: If no schedule is provided.
|
|
115
|
+
|
|
116
|
+
"""
|
|
117
|
+
await self._write_day_schedules(schedule, HotWaterParams.TIME_PROGRAMS)
|
|
118
|
+
|
|
119
|
+
async def _write_day_schedules(
|
|
120
|
+
self,
|
|
121
|
+
schedule: HeatingSchedule | DHWSchedule,
|
|
122
|
+
time_programs: dict[str, str],
|
|
123
|
+
) -> None:
|
|
124
|
+
"""Write populated day schedules using one request per day.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
schedule: Weekly schedule object with per-day DaySchedule attrs.
|
|
128
|
+
time_programs: Mapping of parameter IDs to day names.
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
BSBLANError: If no schedule is provided.
|
|
132
|
+
|
|
133
|
+
"""
|
|
134
|
+
if not schedule.has_any_schedule():
|
|
135
|
+
raise BSBLANError(ErrorMsg.NO_SCHEDULE)
|
|
136
|
+
|
|
137
|
+
# Invert the param mapping to get day_name -> param_id
|
|
138
|
+
# Exclude standard_values as it's not a day of the week
|
|
139
|
+
day_param_map = {
|
|
140
|
+
v: k for k, v in time_programs.items() if v != "standard_values"
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for day_name, param_id in day_param_map.items():
|
|
144
|
+
day_schedule: DaySchedule | None = getattr(schedule, day_name)
|
|
145
|
+
if day_schedule is not None:
|
|
146
|
+
await self._set_device_state(
|
|
147
|
+
self._set_payload(param_id, day_schedule.to_bsblan_format()),
|
|
148
|
+
)
|
bsblan/_thermostat.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Thermostat write preparation for BSB-LAN."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from .constants import CircuitConfig, Validation
|
|
8
|
+
from .exceptions import BSBLANInvalidParameterError
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
|
|
13
|
+
from ._temperature import TemperatureManager
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ThermostatWriter:
|
|
17
|
+
"""Prepare and validate thermostat write payloads."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
*,
|
|
22
|
+
uses_pps_bus: Callable[[], bool],
|
|
23
|
+
temperature: TemperatureManager,
|
|
24
|
+
set_payload: Callable[[str, str], dict[str, Any]],
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Initialize the thermostat writer.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
uses_pps_bus: Returns whether the device uses the PPS bus.
|
|
30
|
+
temperature: Temperature manager for setpoint validation.
|
|
31
|
+
set_payload: Builds a set-parameter payload.
|
|
32
|
+
|
|
33
|
+
"""
|
|
34
|
+
self._uses_pps_bus = uses_pps_bus
|
|
35
|
+
self._temperature = temperature
|
|
36
|
+
self._set_payload = set_payload
|
|
37
|
+
|
|
38
|
+
async def prepare_state( # pylint: disable=too-many-arguments
|
|
39
|
+
self,
|
|
40
|
+
target_temperature: str | None,
|
|
41
|
+
hvac_mode: int | None,
|
|
42
|
+
circuit: int = 1,
|
|
43
|
+
target_temperature_high: str | float | None = None,
|
|
44
|
+
*,
|
|
45
|
+
cooling_operating_mode: int | None = None,
|
|
46
|
+
) -> dict[str, Any]:
|
|
47
|
+
"""Prepare the thermostat state for setting.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
target_temperature (str | None): The target temperature to set.
|
|
51
|
+
hvac_mode (int | None): The HVAC mode to set as raw integer.
|
|
52
|
+
circuit: The heating circuit number (1 or 2).
|
|
53
|
+
target_temperature_high: The cooling comfort setpoint to set.
|
|
54
|
+
cooling_operating_mode: The cooling circuit operating mode to set
|
|
55
|
+
as raw integer.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
dict[str, Any]: The prepared state for the thermostat.
|
|
59
|
+
|
|
60
|
+
"""
|
|
61
|
+
param_ids = self.thermostat_params(circuit)
|
|
62
|
+
state: dict[str, Any] = {}
|
|
63
|
+
if target_temperature is not None:
|
|
64
|
+
await self._temperature.validate_target_temperature(
|
|
65
|
+
target_temperature,
|
|
66
|
+
circuit,
|
|
67
|
+
)
|
|
68
|
+
state.update(
|
|
69
|
+
self._set_payload(param_ids["target_temperature"], target_temperature),
|
|
70
|
+
)
|
|
71
|
+
if target_temperature_high is not None:
|
|
72
|
+
param_id = param_ids.get("target_temperature_high")
|
|
73
|
+
if param_id is None:
|
|
74
|
+
parameter_name = "target_temperature_high"
|
|
75
|
+
raise BSBLANInvalidParameterError(parameter_name)
|
|
76
|
+
await self._temperature.validate_target_temperature_high(
|
|
77
|
+
target_temperature_high,
|
|
78
|
+
circuit,
|
|
79
|
+
)
|
|
80
|
+
state.update(
|
|
81
|
+
self._set_payload(param_id, str(target_temperature_high)),
|
|
82
|
+
)
|
|
83
|
+
if hvac_mode is not None:
|
|
84
|
+
self.validate_hvac_mode(hvac_mode)
|
|
85
|
+
hvac_value = str(hvac_mode)
|
|
86
|
+
if self._uses_pps_bus():
|
|
87
|
+
hvac_value = Validation.PPS_HVAC_MODE_TO_BSBLAN[hvac_mode]
|
|
88
|
+
state.update(
|
|
89
|
+
self._set_payload(param_ids["hvac_mode"], hvac_value),
|
|
90
|
+
)
|
|
91
|
+
if cooling_operating_mode is not None:
|
|
92
|
+
param_id = param_ids.get("cooling_operating_mode")
|
|
93
|
+
if param_id is None:
|
|
94
|
+
parameter_name = "cooling_operating_mode"
|
|
95
|
+
raise BSBLANInvalidParameterError(parameter_name)
|
|
96
|
+
self.validate_cooling_operating_mode(cooling_operating_mode)
|
|
97
|
+
state.update(
|
|
98
|
+
self._set_payload(param_id, str(cooling_operating_mode)),
|
|
99
|
+
)
|
|
100
|
+
return state
|
|
101
|
+
|
|
102
|
+
def thermostat_params(self, circuit: int) -> dict[str, str]:
|
|
103
|
+
"""Return thermostat write parameters for the active bus type."""
|
|
104
|
+
if self._uses_pps_bus():
|
|
105
|
+
return {"target_temperature": "15004", "hvac_mode": "15000"}
|
|
106
|
+
return CircuitConfig.THERMOSTAT_PARAMS[circuit]
|
|
107
|
+
|
|
108
|
+
def validate_hvac_mode(self, hvac_mode: int) -> None:
|
|
109
|
+
"""Validate the HVAC mode.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
hvac_mode (int): The HVAC mode to validate. BSB/LPB accepts 0-3;
|
|
113
|
+
PPS accepts 0, 1, and 3.
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
BSBLANInvalidParameterError: If the HVAC mode is invalid.
|
|
117
|
+
|
|
118
|
+
"""
|
|
119
|
+
valid_modes = (
|
|
120
|
+
Validation.PPS_HVAC_MODES if self._uses_pps_bus() else Validation.HVAC_MODES
|
|
121
|
+
)
|
|
122
|
+
if hvac_mode not in valid_modes:
|
|
123
|
+
raise BSBLANInvalidParameterError(str(hvac_mode))
|
|
124
|
+
|
|
125
|
+
def validate_cooling_operating_mode(self, cooling_operating_mode: int) -> None:
|
|
126
|
+
"""Validate the cooling circuit operating mode.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
cooling_operating_mode (int): The mode to validate. Valid values
|
|
130
|
+
are 0=Protection, 1=Automatic, 2=Reduced, 3=Comfort.
|
|
131
|
+
|
|
132
|
+
Raises:
|
|
133
|
+
BSBLANInvalidParameterError: If the mode is invalid.
|
|
134
|
+
|
|
135
|
+
"""
|
|
136
|
+
if cooling_operating_mode not in Validation.COOLING_OPERATING_MODES:
|
|
137
|
+
raise BSBLANInvalidParameterError(str(cooling_operating_mode))
|