Skip to content

Commit 52c0a40

Browse files
committed
Add support for cooling setpoint in thermostat functionality
- Introduced `target_temperature_high` parameter for setting cooling comfort. - Updated relevant constants and models to accommodate new parameter. - Enhanced tests to validate cooling temperature handling and constraints.
1 parent 598056f commit 52c0a40

8 files changed

Lines changed: 192 additions & 5 deletions

File tree

src/bsblan/bsblan.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1137,6 +1137,7 @@ async def thermostat(
11371137
target_temperature: str | None = None,
11381138
hvac_mode: int | None = None,
11391139
circuit: int = 1,
1140+
target_temperature_high: str | float | None = None,
11401141
) -> None:
11411142
"""Change the state of the thermostat through BSB-Lan.
11421143
@@ -1147,11 +1148,15 @@ async def thermostat(
11471148
For PPS, valid values are 0=off, 1=auto, and 3=heat/manual;
11481149
they are translated to PPS raw values before posting.
11491150
circuit: The heating circuit number (1 or 2). Defaults to 1.
1151+
target_temperature_high: The cooling comfort setpoint to set.
11501152
11511153
Example:
11521154
# Set HC1 temperature
11531155
await client.thermostat(target_temperature="21.0")
11541156
1157+
# Set HC1 cooling comfort setpoint
1158+
await client.thermostat(target_temperature_high="24.0")
1159+
11551160
# Set HC2 mode
11561161
await client.thermostat(hvac_mode=1, circuit=2)
11571162
@@ -1164,13 +1169,15 @@ async def thermostat(
11641169
self._validate_single_parameter(
11651170
target_temperature,
11661171
hvac_mode,
1172+
target_temperature_high,
11671173
error_msg=ErrorMsg.MULTI_PARAMETER,
11681174
)
11691175

11701176
state = await self._prepare_thermostat_state(
11711177
target_temperature,
11721178
hvac_mode,
11731179
circuit,
1180+
target_temperature_high,
11741181
)
11751182
await self._set_device_state(state)
11761183

@@ -1179,13 +1186,15 @@ async def _prepare_thermostat_state(
11791186
target_temperature: str | None,
11801187
hvac_mode: int | None,
11811188
circuit: int = 1,
1189+
target_temperature_high: str | float | None = None,
11821190
) -> dict[str, Any]:
11831191
"""Prepare the thermostat state for setting.
11841192
11851193
Args:
11861194
target_temperature (str | None): The target temperature to set.
11871195
hvac_mode (int | None): The HVAC mode to set as raw integer.
11881196
circuit: The heating circuit number (1 or 2).
1197+
target_temperature_high: The cooling comfort setpoint to set.
11891198
11901199
Returns:
11911200
dict[str, Any]: The prepared state for the thermostat.
@@ -1205,6 +1214,19 @@ async def _prepare_thermostat_state(
12051214
"Type": "1",
12061215
},
12071216
)
1217+
if target_temperature_high is not None:
1218+
self._validate_target_temperature_high(target_temperature_high)
1219+
param_id = param_ids.get("target_temperature_high")
1220+
if param_id is None:
1221+
parameter_name = "target_temperature_high"
1222+
raise BSBLANInvalidParameterError(parameter_name)
1223+
state.update(
1224+
{
1225+
"Parameter": param_id,
1226+
"Value": str(target_temperature_high),
1227+
"Type": "1",
1228+
},
1229+
)
12081230
if hvac_mode is not None:
12091231
self._validate_hvac_mode(hvac_mode)
12101232
hvac_value = str(hvac_mode)
@@ -1225,6 +1247,16 @@ def _thermostat_params(self, circuit: int) -> dict[str, str]:
12251247
return {"target_temperature": "15004", "hvac_mode": "15000"}
12261248
return CircuitConfig.THERMOSTAT_PARAMS[circuit]
12271249

1250+
def _validate_target_temperature_high(
1251+
self,
1252+
target_temperature_high: str | float,
1253+
) -> None:
1254+
"""Validate the cooling target temperature value."""
1255+
try:
1256+
float(target_temperature_high)
1257+
except ValueError as err:
1258+
raise BSBLANInvalidParameterError(str(target_temperature_high)) from err
1259+
12281260
async def _validate_target_temperature(
12291261
self,
12301262
target_temperature: str,

src/bsblan/constants.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ class APIConfig(TypedDict):
2929
"700": "hvac_mode",
3030
"710": "target_temperature",
3131
"900": "hvac_mode_changeover",
32+
"902": "target_temperature_high",
3233
# -------
3334
"8000": "hvac_action",
3435
"8740": "current_temperature",
@@ -154,7 +155,11 @@ class CircuitConfig:
154155
2: "staticValues_circuit2",
155156
}
156157
THERMOSTAT_PARAMS: Final[dict[int, dict[str, str]]] = {
157-
1: {"target_temperature": "710", "hvac_mode": "700"},
158+
1: {
159+
"target_temperature": "710",
160+
"target_temperature_high": "902",
161+
"hvac_mode": "700",
162+
},
158163
2: {"target_temperature": "1010", "hvac_mode": "1000"},
159164
}
160165
PROBE_PARAMS: Final[dict[int, str]] = {

src/bsblan/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,7 @@ class State(BaseModel):
469469

470470
hvac_mode: EntityInfo[int] | None = None
471471
target_temperature: EntityInfo[float] | None = None
472+
target_temperature_high: EntityInfo[float] | None = None
472473
hvac_action: EntityInfo[int] | None = None
473474
hvac_mode_changeover: EntityInfo[int] | None = None
474475
current_temperature: EntityInfo[float] | None = None

tests/fixtures/state.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,19 @@
3636
"readwrite": 0,
3737
"unit": ""
3838
},
39+
"902": {
40+
"name": "Cooling circuit 1 - Comfort setpoint",
41+
"dataType_name": "TEMP",
42+
"dataType_family": "VALS",
43+
"error": 0,
44+
"value": "21.0",
45+
"desc": "",
46+
"precision": 0.1,
47+
"dataType": 0,
48+
"readonly": 0,
49+
"readwrite": 0,
50+
"unit": "°C"
51+
},
3952
"8000": {
4053
"name": "Status heating circuit 1",
4154
"dataType_name": "ENUM",

tests/test_circuit.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,24 @@ async def test_thermostat_circuit2_no_params(
397397
assert str(exc_info.value) == ErrorMsg.MULTI_PARAMETER
398398

399399

400+
@pytest.mark.asyncio
401+
async def test_thermostat_circuit2_rejects_cooling_temperature(
402+
mock_bsblan_circuit: BSBLAN,
403+
) -> None:
404+
"""Test cooling target writes are only mapped for circuit 1."""
405+
mock_bsblan_circuit._circuit_temp_ranges[2] = {
406+
"min": 8.0,
407+
"max": 28.0,
408+
}
409+
mock_bsblan_circuit._circuit_temp_initialized.add(2)
410+
411+
with pytest.raises(BSBLANInvalidParameterError):
412+
await mock_bsblan_circuit.thermostat(
413+
target_temperature_high="24",
414+
circuit=2,
415+
)
416+
417+
400418
# --- Temperature range initialization tests ---
401419

402420

tests/test_constants.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,17 @@
2121
[
2222
(
2323
"v1",
24-
{"700", "710", "714", "730"}, # hvac_mode, target_temp, min_temp, v1_max
24+
{"700", "710", "902", "714", "730"},
2525
{"770", "716"}, # v3_boost, v3_max_temp
2626
),
2727
(
2828
"v3",
29-
{"700", "710", "714", "770", "716"}, # base + v3 extensions
29+
{"700", "710", "902", "714", "770", "716"},
3030
{"730"}, # v1_max_temp
3131
),
3232
(
3333
"v5", # Unknown version
34-
{"700", "710", "714"}, # only base parameters
34+
{"700", "710", "902", "714"}, # only base parameters
3535
{"770", "730", "716"}, # no extensions
3636
),
3737
],
@@ -138,6 +138,15 @@ def test_hot_water_parameter_groups_total_count() -> None:
138138
assert total_grouped == len(BASE_HOT_WATER_PARAMS)
139139

140140

141+
def test_cooling_target_uses_single_base_parameter() -> None:
142+
"""Test cooling setpoint uses 902, not duplicate decimal parameters."""
143+
config = build_api_config("v3")
144+
145+
assert config["heating"]["902"] == "target_temperature_high"
146+
assert "902.1" not in config["heating"]
147+
assert "902.2" not in config["heating"]
148+
149+
141150
@pytest.mark.parametrize("version", ["v1", "v3"])
142151
def test_api_config_structure(version: str) -> None:
143152
"""Test that API config has required structure."""

tests/test_state.py

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@ async def test_state(monkeypatch: Any) -> None:
5959
assert state.target_temperature.desc == ""
6060
assert state.target_temperature.unit == "°C"
6161

62+
# Cooling target temperature assertions
63+
assert state.target_temperature_high is not None
64+
assert state.target_temperature_high.value == 21.0
65+
assert state.target_temperature_high.desc == ""
66+
assert state.target_temperature_high.unit == "°C"
67+
6268
# HVAC mode changeover assertions
6369
assert state.hvac_mode_changeover is not None
6470
assert state.hvac_mode_changeover.value == 2
@@ -81,5 +87,68 @@ async def test_state(monkeypatch: Any) -> None:
8187

8288
# Verify API call
8389
request_mock.assert_called_once_with(
84-
params={"Parameter": "700,710,900,8000,8740,770"}
90+
params={"Parameter": "700,710,900,902,8000,8740,770"}
8591
)
92+
93+
94+
@pytest.mark.asyncio
95+
async def test_state_with_cooling_include(monkeypatch: Any) -> None:
96+
"""Test fetching only the cooling target temperature."""
97+
async with aiohttp.ClientSession() as session:
98+
config = BSBLANConfig(host="example.com")
99+
bsblan = BSBLAN(config, session=session)
100+
101+
monkeypatch.setattr(bsblan, "_firmware_version", "1.0.38-20200730234859")
102+
monkeypatch.setattr(bsblan, "_api_version", "v3")
103+
monkeypatch.setattr(bsblan, "_api_data", API_V3)
104+
105+
api_validator = APIValidator(API_V3)
106+
api_validator.validated_sections.add("heating")
107+
bsblan._api_validator = api_validator
108+
109+
state_data = json.loads(load_fixture("state.json"))
110+
request_mock: AsyncMock = AsyncMock(return_value={"902": state_data["902"]})
111+
monkeypatch.setattr(bsblan, "_request", request_mock)
112+
113+
state: State = await bsblan.state(include=["target_temperature_high"])
114+
115+
assert state.target_temperature_high is not None
116+
assert state.target_temperature_high.value == 21.0
117+
assert state.target_temperature is None
118+
request_mock.assert_awaited_once_with(params={"Parameter": "902"})
119+
120+
121+
@pytest.mark.asyncio
122+
async def test_state_without_cooling_strips_target_temperature_high(
123+
monkeypatch: Any,
124+
) -> None:
125+
"""Test unsupported cooling setpoint is stripped during validation."""
126+
async with aiohttp.ClientSession() as session:
127+
config = BSBLANConfig(host="example.com")
128+
bsblan = BSBLAN(config, session=session)
129+
130+
monkeypatch.setattr(bsblan, "_firmware_version", "1.0.38-20200730234859")
131+
monkeypatch.setattr(bsblan, "_api_version", "v3")
132+
bsblan._api_data = {
133+
section: params.copy() for section, params in API_V3.items()
134+
}
135+
bsblan._api_validator = APIValidator(bsblan._api_data)
136+
137+
state_data = json.loads(load_fixture("state.json"))
138+
validation_response = {"700": state_data["700"]}
139+
fetch_response = {"700": state_data["700"]}
140+
request_mock: AsyncMock = AsyncMock(
141+
side_effect=[validation_response, fetch_response]
142+
)
143+
monkeypatch.setattr(bsblan, "_request", request_mock)
144+
145+
state: State = await bsblan.state(
146+
include=["hvac_mode", "target_temperature_high"]
147+
)
148+
149+
assert state.hvac_mode is not None
150+
assert state.target_temperature_high is None
151+
assert bsblan.get_parameter_id("target_temperature_high") is None
152+
assert [
153+
call.kwargs["params"]["Parameter"] for call in request_mock.await_args_list
154+
] == ["700,902", "700"]

tests/test_thermostat.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,26 @@ async def test_change_temperature(
117117
await mock_bsblan.thermostat(target_temperature="20")
118118

119119

120+
@pytest.mark.asyncio
121+
async def test_change_cooling_temperature(
122+
mock_bsblan: BSBLAN,
123+
mock_aresponses: ResponsesMockServer,
124+
) -> None:
125+
"""Test changing BSBLAN cooling setpoint."""
126+
expected_data = {
127+
"Parameter": "902",
128+
"Value": "32.0",
129+
"Type": "1",
130+
}
131+
mock_aresponses.add(
132+
"example.com",
133+
"/JS",
134+
"POST",
135+
create_response_handler(expected_data),
136+
)
137+
await mock_bsblan.thermostat(target_temperature_high=32.0)
138+
139+
120140
@pytest.mark.asyncio
121141
async def test_change_hvac_mode(
122142
mock_bsblan: BSBLAN,
@@ -144,6 +164,13 @@ async def test_invalid_temperature(mock_bsblan: BSBLAN) -> None:
144164
await mock_bsblan.thermostat(target_temperature="35")
145165

146166

167+
@pytest.mark.asyncio
168+
async def test_invalid_cooling_temperature(mock_bsblan: BSBLAN) -> None:
169+
"""Test setting an invalid cooling temperature."""
170+
with pytest.raises(BSBLANInvalidParameterError):
171+
await mock_bsblan.thermostat(target_temperature_high="invalid")
172+
173+
147174
@pytest.mark.asyncio
148175
@pytest.mark.parametrize(
149176
"invalid_mode",
@@ -191,3 +218,16 @@ async def test_no_parameters(mock_bsblan: BSBLAN) -> None:
191218
with pytest.raises(BSBLANError) as exc_info:
192219
await mock_bsblan.thermostat()
193220
assert str(exc_info.value) == ErrorMsg.MULTI_PARAMETER
221+
222+
223+
@pytest.mark.asyncio
224+
async def test_cooling_temperature_with_other_parameter_raises(
225+
mock_bsblan: BSBLAN,
226+
) -> None:
227+
"""Test cooling setpoint respects one-parameter-per-call rule."""
228+
with pytest.raises(BSBLANError) as exc_info:
229+
await mock_bsblan.thermostat(
230+
target_temperature="20",
231+
target_temperature_high="24",
232+
)
233+
assert str(exc_info.value) == ErrorMsg.MULTI_PARAMETER

0 commit comments

Comments
 (0)