From 37edf982ca414c263c0ba624290c1afb64c5d2c3 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Fri, 22 Nov 2024 12:17:53 +0100 Subject: [PATCH] Add waterheater platform bsblan (#129053) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/bsblan/__init__.py | 2 +- homeassistant/components/bsblan/climate.py | 19 +- .../components/bsblan/coordinator.py | 6 +- homeassistant/components/bsblan/sensor.py | 6 +- homeassistant/components/bsblan/strings.json | 6 + .../components/bsblan/water_heater.py | 107 +++++++++ tests/components/bsblan/conftest.py | 7 +- .../components/bsblan/fixtures/dhw_state.json | 110 +++++++++ .../bsblan/snapshots/test_climate.ambr | 77 +------ .../bsblan/snapshots/test_water_heater.ambr | 68 ++++++ tests/components/bsblan/test_climate.py | 37 +-- tests/components/bsblan/test_sensor.py | 42 +--- tests/components/bsblan/test_water_heater.py | 210 ++++++++++++++++++ 13 files changed, 528 insertions(+), 169 deletions(-) create mode 100644 homeassistant/components/bsblan/water_heater.py create mode 100644 tests/components/bsblan/fixtures/dhw_state.json create mode 100644 tests/components/bsblan/snapshots/test_water_heater.ambr create mode 100644 tests/components/bsblan/test_water_heater.py diff --git a/homeassistant/components/bsblan/__init__.py b/homeassistant/components/bsblan/__init__.py index 4d3c6ee2073..623bfbfef56 100644 --- a/homeassistant/components/bsblan/__init__.py +++ b/homeassistant/components/bsblan/__init__.py @@ -18,7 +18,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_PASSKEY from .coordinator import BSBLanUpdateCoordinator -PLATFORMS = [Platform.CLIMATE, Platform.SENSOR] +PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER] type BSBLanConfigEntry = ConfigEntry[BSBLanData] diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py index fcbe88f2fac..6d992da395a 100644 --- a/homeassistant/components/bsblan/climate.py +++ b/homeassistant/components/bsblan/climate.py @@ -15,7 +15,7 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.const import ATTR_TEMPERATURE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.device_registry import format_mac @@ -75,26 +75,19 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity): super().__init__(data.coordinator, data) self._attr_unique_id = f"{format_mac(data.device.MAC)}-climate" - self._attr_min_temp = float(data.static.min_temp.value) - self._attr_max_temp = float(data.static.max_temp.value) - if data.static.min_temp.unit in ("°C", "°C"): - self._attr_temperature_unit = UnitOfTemperature.CELSIUS - else: - self._attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + self._attr_min_temp = data.static.min_temp.value + self._attr_max_temp = data.static.max_temp.value + self._attr_temperature_unit = data.coordinator.client.get_temperature_unit @property def current_temperature(self) -> float | None: """Return the current temperature.""" - if self.coordinator.data.state.current_temperature.value == "---": - # device returns no current temperature - return None - - return float(self.coordinator.data.state.current_temperature.value) + return self.coordinator.data.state.current_temperature.value @property def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" - return float(self.coordinator.data.state.target_temperature.value) + return self.coordinator.data.state.target_temperature.value @property def hvac_mode(self) -> HVACMode | None: diff --git a/homeassistant/components/bsblan/coordinator.py b/homeassistant/components/bsblan/coordinator.py index 1a4299fe72f..be9030d95b0 100644 --- a/homeassistant/components/bsblan/coordinator.py +++ b/homeassistant/components/bsblan/coordinator.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from datetime import timedelta from random import randint -from bsblan import BSBLAN, BSBLANConnectionError, Sensor, State +from bsblan import BSBLAN, BSBLANConnectionError, HotWaterState, Sensor, State from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST @@ -20,6 +20,7 @@ class BSBLanCoordinatorData: state: State sensor: Sensor + dhw: HotWaterState class BSBLanUpdateCoordinator(DataUpdateCoordinator[BSBLanCoordinatorData]): @@ -59,6 +60,7 @@ class BSBLanUpdateCoordinator(DataUpdateCoordinator[BSBLanCoordinatorData]): state = await self.client.state() sensor = await self.client.sensor() + dhw = await self.client.hot_water_state() except BSBLANConnectionError as err: host = self.config_entry.data[CONF_HOST] if self.config_entry else "unknown" raise UpdateFailed( @@ -66,4 +68,4 @@ class BSBLanUpdateCoordinator(DataUpdateCoordinator[BSBLanCoordinatorData]): ) from err self.update_interval = self._get_update_interval() - return BSBLanCoordinatorData(state=state, sensor=sensor) + return BSBLanCoordinatorData(state=state, sensor=sensor, dhw=dhw) diff --git a/homeassistant/components/bsblan/sensor.py b/homeassistant/components/bsblan/sensor.py index eab03d7a50c..c13b4ad7650 100644 --- a/homeassistant/components/bsblan/sensor.py +++ b/homeassistant/components/bsblan/sensor.py @@ -72,11 +72,9 @@ class BSBLanSensor(BSBLanEntity, SensorEntity): super().__init__(data.coordinator, data) self.entity_description = description self._attr_unique_id = f"{data.device.MAC}-{description.key}" + self._attr_temperature_unit = data.coordinator.client.get_temperature_unit @property def native_value(self) -> StateType: """Return the state of the sensor.""" - value = self.entity_description.value_fn(self.coordinator.data) - if value == "---": - return None - return value + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/bsblan/strings.json b/homeassistant/components/bsblan/strings.json index 4fb374fee75..a73a89ca1cc 100644 --- a/homeassistant/components/bsblan/strings.json +++ b/homeassistant/components/bsblan/strings.json @@ -31,6 +31,12 @@ }, "set_data_error": { "message": "An error occurred while sending the data to the BSBLAN device" + }, + "set_temperature_error": { + "message": "An error occurred while setting the temperature" + }, + "set_operation_mode_error": { + "message": "An error occurred while setting the operation mode" } }, "entity": { diff --git a/homeassistant/components/bsblan/water_heater.py b/homeassistant/components/bsblan/water_heater.py new file mode 100644 index 00000000000..318408a9124 --- /dev/null +++ b/homeassistant/components/bsblan/water_heater.py @@ -0,0 +1,107 @@ +"""BSBLAN platform to control a compatible Water Heater Device.""" + +from __future__ import annotations + +from typing import Any + +from bsblan import BSBLANError + +from homeassistant.components.water_heater import ( + STATE_ECO, + STATE_OFF, + WaterHeaterEntity, + WaterHeaterEntityFeature, +) +from homeassistant.const import ATTR_TEMPERATURE, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import BSBLanConfigEntry, BSBLanData +from .const import DOMAIN +from .entity import BSBLanEntity + +PARALLEL_UPDATES = 1 + +# Mapping between BSBLan and HA operation modes +OPERATION_MODES = { + "Eco": STATE_ECO, # Energy saving mode + "Off": STATE_OFF, # Protection mode + "On": STATE_ON, # Continuous comfort mode +} + +OPERATION_MODES_REVERSE = {v: k for k, v in OPERATION_MODES.items()} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: BSBLanConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up BSBLAN water heater based on a config entry.""" + data = entry.runtime_data + async_add_entities([BSBLANWaterHeater(data)]) + + +class BSBLANWaterHeater(BSBLanEntity, WaterHeaterEntity): + """Defines a BSBLAN water heater entity.""" + + _attr_name = None + _attr_supported_features = ( + WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.OPERATION_MODE + ) + + def __init__(self, data: BSBLanData) -> None: + """Initialize BSBLAN water heater.""" + super().__init__(data.coordinator, data) + self._attr_unique_id = format_mac(data.device.MAC) + self._attr_operation_list = list(OPERATION_MODES_REVERSE.keys()) + + # Set temperature limits based on device capabilities + self._attr_temperature_unit = data.coordinator.client.get_temperature_unit + self._attr_min_temp = data.coordinator.data.dhw.reduced_setpoint.value + self._attr_max_temp = data.coordinator.data.dhw.nominal_setpoint_max.value + + @property + def current_operation(self) -> str | None: + """Return current operation.""" + current_mode = self.coordinator.data.dhw.operating_mode.desc + return OPERATION_MODES.get(current_mode) + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + return self.coordinator.data.dhw.dhw_actual_value_top_temperature.value + + @property + def target_temperature(self) -> float | None: + """Return the temperature we try to reach.""" + return self.coordinator.data.dhw.nominal_setpoint.value + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + try: + await self.coordinator.client.set_hot_water(nominal_setpoint=temperature) + except BSBLANError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_temperature_error", + ) from err + + await self.coordinator.async_request_refresh() + + async def async_set_operation_mode(self, operation_mode: str) -> None: + """Set new operation mode.""" + bsblan_mode = OPERATION_MODES_REVERSE.get(operation_mode) + try: + await self.coordinator.client.set_hot_water(operating_mode=bsblan_mode) + except BSBLANError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_operation_mode_error", + ) from err + + await self.coordinator.async_request_refresh() diff --git a/tests/components/bsblan/conftest.py b/tests/components/bsblan/conftest.py index e46cdd75f2d..7d2db2f8b46 100644 --- a/tests/components/bsblan/conftest.py +++ b/tests/components/bsblan/conftest.py @@ -3,7 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch -from bsblan import Device, Info, Sensor, State, StaticState +from bsblan import Device, HotWaterState, Info, Sensor, State, StaticState import pytest from homeassistant.components.bsblan.const import CONF_PASSKEY, DOMAIN @@ -58,6 +58,11 @@ def mock_bsblan() -> Generator[MagicMock]: bsblan.sensor.return_value = Sensor.from_json( load_fixture("sensor.json", DOMAIN) ) + bsblan.hot_water_state.return_value = HotWaterState.from_json( + load_fixture("dhw_state.json", DOMAIN) + ) + # mock get_temperature_unit property + bsblan.get_temperature_unit = "°C" yield bsblan diff --git a/tests/components/bsblan/fixtures/dhw_state.json b/tests/components/bsblan/fixtures/dhw_state.json new file mode 100644 index 00000000000..41b8c7beda5 --- /dev/null +++ b/tests/components/bsblan/fixtures/dhw_state.json @@ -0,0 +1,110 @@ +{ + "operating_mode": { + "name": "DHW operating mode", + "error": 0, + "value": "On", + "desc": "On", + "dataType": 1, + "readonly": 0, + "unit": "" + }, + "nominal_setpoint": { + "name": "DHW nominal setpoint", + "error": 0, + "value": "50.0", + "desc": "", + "dataType": 0, + "readonly": 0, + "unit": "°C" + }, + "nominal_setpoint_max": { + "name": "DHW nominal setpoint maximum", + "error": 0, + "value": "65.0", + "desc": "", + "dataType": 0, + "readonly": 0, + "unit": "°C" + }, + "reduced_setpoint": { + "name": "DHW reduced setpoint", + "error": 0, + "value": "40.0", + "desc": "", + "dataType": 0, + "readonly": 0, + "unit": "°C" + }, + "release": { + "name": "DHW release programme", + "error": 0, + "value": "1", + "desc": "Released", + "dataType": 1, + "readonly": 0, + "unit": "" + }, + "legionella_function": { + "name": "Legionella function fixed weekday", + "error": 0, + "value": "0", + "desc": "Off", + "dataType": 1, + "readonly": 0, + "unit": "" + }, + "legionella_setpoint": { + "name": "Legionella function setpoint", + "error": 0, + "value": "60.0", + "desc": "", + "dataType": 0, + "readonly": 0, + "unit": "°C" + }, + "legionella_periodicity": { + "name": "Legionella function periodicity", + "error": 0, + "value": "7", + "desc": "Weekly", + "dataType": 0, + "readonly": 0, + "unit": "days" + }, + "legionella_function_day": { + "name": "Legionella function day", + "error": 0, + "value": "6", + "desc": "Saturday", + "dataType": 1, + "readonly": 0, + "unit": "" + }, + "legionella_function_time": { + "name": "Legionella function time", + "error": 0, + "value": "12:00", + "desc": "", + "dataType": 2, + "readonly": 0, + "unit": "" + }, + "dhw_actual_value_top_temperature": { + "name": "DHW temperature actual value", + "error": 0, + "value": "48.5", + "desc": "", + "dataType": 0, + "readonly": 1, + "unit": "°C" + }, + "state_dhw_pump": { + "name": "State DHW circulation pump", + "error": 0, + "value": "0", + "desc": "Off", + "dataType": 1, + "readonly": 1, + "unit": "" + } +} diff --git a/tests/components/bsblan/snapshots/test_climate.ambr b/tests/components/bsblan/snapshots/test_climate.ambr index 4eb70fe2658..16828fea752 100644 --- a/tests/components/bsblan/snapshots/test_climate.ambr +++ b/tests/components/bsblan/snapshots/test_climate.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_celsius_fahrenheit[static.json][climate.bsb_lan-entry] +# name: test_celsius_fahrenheit[climate.bsb_lan-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -44,7 +44,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_celsius_fahrenheit[static.json][climate.bsb_lan-state] +# name: test_celsius_fahrenheit[climate.bsb_lan-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 18.6, @@ -72,79 +72,6 @@ 'state': 'heat', }) # --- -# name: test_celsius_fahrenheit[static_F.json][climate.bsb_lan-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'hvac_modes': list([ - , - , - , - ]), - 'max_temp': -6.7, - 'min_temp': -13.3, - 'preset_modes': list([ - 'eco', - 'none', - ]), - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'climate', - 'entity_category': None, - 'entity_id': 'climate.bsb_lan', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'bsblan', - 'previous_unique_id': None, - 'supported_features': , - 'translation_key': None, - 'unique_id': '00:80:41:19:69:90-climate', - 'unit_of_measurement': None, - }) -# --- -# name: test_celsius_fahrenheit[static_F.json][climate.bsb_lan-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'current_temperature': -7.4, - 'friendly_name': 'BSB-LAN', - 'hvac_modes': list([ - , - , - , - ]), - 'max_temp': -6.7, - 'min_temp': -13.3, - 'preset_mode': 'none', - 'preset_modes': list([ - 'eco', - 'none', - ]), - 'supported_features': , - 'temperature': -7.5, - }), - 'context': , - 'entity_id': 'climate.bsb_lan', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'heat', - }) -# --- # name: test_climate_entity_properties[climate.bsb_lan-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/bsblan/snapshots/test_water_heater.ambr b/tests/components/bsblan/snapshots/test_water_heater.ambr new file mode 100644 index 00000000000..c1a13b764c0 --- /dev/null +++ b/tests/components/bsblan/snapshots/test_water_heater.ambr @@ -0,0 +1,68 @@ +# serializer version: 1 +# name: test_water_heater_states[dhw_state.json][water_heater.bsb_lan-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_temp': 65.0, + 'min_temp': 40.0, + 'operation_list': list([ + 'eco', + 'off', + 'on', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'water_heater', + 'entity_category': None, + 'entity_id': 'water_heater.bsb_lan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'bsblan', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:80:41:19:69:90', + 'unit_of_measurement': None, + }) +# --- +# name: test_water_heater_states[dhw_state.json][water_heater.bsb_lan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 48.5, + 'friendly_name': 'BSB-LAN', + 'max_temp': 65.0, + 'min_temp': 40.0, + 'operation_list': list([ + 'eco', + 'off', + 'on', + ]), + 'operation_mode': 'on', + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 50.0, + }), + 'context': , + 'entity_id': 'water_heater.bsb_lan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/bsblan/test_climate.py b/tests/components/bsblan/test_climate.py index c519c3043da..7ee12c5fa1a 100644 --- a/tests/components/bsblan/test_climate.py +++ b/tests/components/bsblan/test_climate.py @@ -3,12 +3,11 @@ from datetime import timedelta from unittest.mock import AsyncMock, MagicMock -from bsblan import BSBLANError, StaticState +from bsblan import BSBLANError from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.bsblan.const import DOMAIN from homeassistant.components.climate import ( ATTR_HVAC_MODE, ATTR_PRESET_MODE, @@ -27,37 +26,19 @@ import homeassistant.helpers.entity_registry as er from . import setup_with_selected_platforms -from tests.common import ( - MockConfigEntry, - async_fire_time_changed, - load_json_object_fixture, - snapshot_platform, -) +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform ENTITY_ID = "climate.bsb_lan" -@pytest.mark.parametrize( - ("static_file"), - [ - ("static.json"), - ("static_F.json"), - ], -) async def test_celsius_fahrenheit( hass: HomeAssistant, mock_bsblan: AsyncMock, mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, - static_file: str, ) -> None: """Test Celsius and Fahrenheit temperature units.""" - - static_data = load_json_object_fixture(static_file, DOMAIN) - - mock_bsblan.static_values.return_value = StaticState.from_dict(static_data) - await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @@ -75,21 +56,9 @@ async def test_climate_entity_properties( await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) - # Test when current_temperature is "---" - mock_current_temp = MagicMock() - mock_current_temp.value = "---" - mock_bsblan.state.return_value.current_temperature = mock_current_temp - - freezer.tick(timedelta(minutes=1)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - state = hass.states.get(ENTITY_ID) - assert state.attributes["current_temperature"] is None - # Test target_temperature mock_target_temp = MagicMock() - mock_target_temp.value = "23.5" + mock_target_temp.value = 23.5 mock_bsblan.state.return_value.target_temperature = mock_target_temp freezer.tick(timedelta(minutes=1)) diff --git a/tests/components/bsblan/test_sensor.py b/tests/components/bsblan/test_sensor.py index dc22574168d..c95671a1a6b 100644 --- a/tests/components/bsblan/test_sensor.py +++ b/tests/components/bsblan/test_sensor.py @@ -1,19 +1,17 @@ """Tests for the BSB-Lan sensor platform.""" -from datetime import timedelta -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory -import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant import homeassistant.helpers.entity_registry as er from . import setup_with_selected_platforms -from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +from tests.common import MockConfigEntry, snapshot_platform ENTITY_CURRENT_TEMP = "sensor.bsb_lan_current_temperature" ENTITY_OUTSIDE_TEMP = "sensor.bsb_lan_outside_temperature" @@ -30,37 +28,3 @@ async def test_sensor_entity_properties( """Test the sensor entity properties.""" await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SENSOR]) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) - - -@pytest.mark.parametrize( - ("value", "expected_state"), - [ - (18.6, "18.6"), - (None, STATE_UNKNOWN), - ("---", STATE_UNKNOWN), - ], -) -async def test_current_temperature_scenarios( - hass: HomeAssistant, - mock_bsblan: AsyncMock, - mock_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, - value, - expected_state, -) -> None: - """Test various scenarios for current temperature sensor.""" - await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SENSOR]) - - # Set up the mock value - mock_current_temp = MagicMock() - mock_current_temp.value = value - mock_bsblan.sensor.return_value.current_temperature = mock_current_temp - - # Trigger an update - freezer.tick(timedelta(minutes=1)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - # Check the state - state = hass.states.get(ENTITY_CURRENT_TEMP) - assert state.state == expected_state diff --git a/tests/components/bsblan/test_water_heater.py b/tests/components/bsblan/test_water_heater.py new file mode 100644 index 00000000000..ed920774aa5 --- /dev/null +++ b/tests/components/bsblan/test_water_heater.py @@ -0,0 +1,210 @@ +"""Tests for the BSB-Lan water heater platform.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock + +from bsblan import BSBLANError +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.water_heater import ( + ATTR_OPERATION_MODE, + DOMAIN as WATER_HEATER_DOMAIN, + SERVICE_SET_OPERATION_MODE, + SERVICE_SET_TEMPERATURE, + STATE_ECO, + STATE_OFF, + STATE_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +import homeassistant.helpers.entity_registry as er + +from . import setup_with_selected_platforms + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +ENTITY_ID = "water_heater.bsb_lan" + + +@pytest.mark.parametrize( + ("dhw_file"), + [ + ("dhw_state.json"), + ], +) +async def test_water_heater_states( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + dhw_file: str, +) -> None: + """Test water heater states with different configurations.""" + await setup_with_selected_platforms( + hass, mock_config_entry, [Platform.WATER_HEATER] + ) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_water_heater_entity_properties( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the water heater entity properties.""" + await setup_with_selected_platforms( + hass, mock_config_entry, [Platform.WATER_HEATER] + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + + # Test when nominal setpoint is "10" + mock_setpoint = MagicMock() + mock_setpoint.value = 10 + mock_bsblan.hot_water_state.return_value.nominal_setpoint = mock_setpoint + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.attributes.get("temperature") == 10 + + +@pytest.mark.parametrize( + ("mode", "bsblan_mode"), + [ + (STATE_ECO, "Eco"), + (STATE_OFF, "Off"), + (STATE_ON, "On"), + ], +) +async def test_set_operation_mode( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, + mode: str, + bsblan_mode: str, +) -> None: + """Test setting operation mode.""" + await setup_with_selected_platforms( + hass, mock_config_entry, [Platform.WATER_HEATER] + ) + + await hass.services.async_call( + domain=WATER_HEATER_DOMAIN, + service=SERVICE_SET_OPERATION_MODE, + service_data={ + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_OPERATION_MODE: mode, + }, + blocking=True, + ) + + mock_bsblan.set_hot_water.assert_called_once_with(operating_mode=bsblan_mode) + + +async def test_set_invalid_operation_mode( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting invalid operation mode.""" + await setup_with_selected_platforms( + hass, mock_config_entry, [Platform.WATER_HEATER] + ) + + with pytest.raises( + HomeAssistantError, + match=r"Operation mode invalid_mode is not valid for water_heater\.bsb_lan\. Valid operation modes are: eco, off, on", + ): + await hass.services.async_call( + domain=WATER_HEATER_DOMAIN, + service=SERVICE_SET_OPERATION_MODE, + service_data={ + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_OPERATION_MODE: "invalid_mode", + }, + blocking=True, + ) + + +async def test_set_temperature( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting temperature.""" + await setup_with_selected_platforms( + hass, mock_config_entry, [Platform.WATER_HEATER] + ) + + await hass.services.async_call( + domain=WATER_HEATER_DOMAIN, + service=SERVICE_SET_TEMPERATURE, + service_data={ + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_TEMPERATURE: 50, + }, + blocking=True, + ) + + mock_bsblan.set_hot_water.assert_called_once_with(nominal_setpoint=50) + + +async def test_set_temperature_failure( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting temperature with API failure.""" + await setup_with_selected_platforms( + hass, mock_config_entry, [Platform.WATER_HEATER] + ) + + mock_bsblan.set_hot_water.side_effect = BSBLANError("Test error") + + with pytest.raises( + HomeAssistantError, match="An error occurred while setting the temperature" + ): + await hass.services.async_call( + domain=WATER_HEATER_DOMAIN, + service=SERVICE_SET_TEMPERATURE, + service_data={ + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_TEMPERATURE: 50, + }, + blocking=True, + ) + + +async def test_operation_mode_error( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test operation mode setting with API failure.""" + await setup_with_selected_platforms( + hass, mock_config_entry, [Platform.WATER_HEATER] + ) + + mock_bsblan.set_hot_water.side_effect = BSBLANError("Test error") + + with pytest.raises( + HomeAssistantError, match="An error occurred while setting the operation mode" + ): + await hass.services.async_call( + domain=WATER_HEATER_DOMAIN, + service=SERVICE_SET_OPERATION_MODE, + service_data={ + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_OPERATION_MODE: STATE_ECO, + }, + blocking=True, + )