diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py index ae7116143df9c3..3a204a9e0c244f 100644 --- a/homeassistant/components/bsblan/climate.py +++ b/homeassistant/components/bsblan/climate.py @@ -126,15 +126,14 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: async def async_set_preset_mode(self, preset_mode: str) -> None: """Set preset mode.""" - # only allow preset mode when hvac mode is auto - if self.hvac_mode == HVACMode.AUTO: - await self.async_set_data(preset_mode=preset_mode) - else: + if self.hvac_mode != HVACMode.AUTO and preset_mode != PRESET_NONE: raise ServiceValidationError( + "Preset mode can only be set when HVAC mode is set to 'auto'", translation_domain=DOMAIN, translation_key="set_preset_mode_error", translation_placeholders={"preset_mode": preset_mode}, ) + await self.async_set_data(preset_mode=preset_mode) async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperatures.""" @@ -148,11 +147,11 @@ async def async_set_data(self, **kwargs: Any) -> None: if ATTR_HVAC_MODE in kwargs: data[ATTR_HVAC_MODE] = kwargs[ATTR_HVAC_MODE] if ATTR_PRESET_MODE in kwargs: - # If preset mode is None, set hvac to auto - if kwargs[ATTR_PRESET_MODE] == PRESET_NONE: - data[ATTR_HVAC_MODE] = HVACMode.AUTO - else: - data[ATTR_HVAC_MODE] = kwargs[ATTR_PRESET_MODE] + if kwargs[ATTR_PRESET_MODE] == PRESET_ECO: + data[ATTR_HVAC_MODE] = PRESET_ECO + elif kwargs[ATTR_PRESET_MODE] == PRESET_NONE: + data[ATTR_HVAC_MODE] = PRESET_NONE + try: await self.coordinator.client.thermostat(**data) except BSBLANError as err: diff --git a/tests/components/bsblan/__init__.py b/tests/components/bsblan/__init__.py index d233fa068ea95e..3892fcaaaca1a9 100644 --- a/tests/components/bsblan/__init__.py +++ b/tests/components/bsblan/__init__.py @@ -1 +1,18 @@ """Tests for the bsblan integration.""" + +from unittest.mock import patch + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_with_selected_platforms( + hass: HomeAssistant, config_entry: MockConfigEntry, platforms: list[Platform] +) -> None: + """Set up the BSBLAN integration with the selected platforms.""" + config_entry.add_to_hass(hass) + with patch("homeassistant.components.bsblan.PLATFORMS", platforms): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/bsblan/conftest.py b/tests/components/bsblan/conftest.py index 13d4017d7c8bbf..96445a4bb2349a 100644 --- a/tests/components/bsblan/conftest.py +++ b/tests/components/bsblan/conftest.py @@ -40,7 +40,7 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture -def mock_bsblan() -> Generator[MagicMock]: +def mock_bsblan() -> Generator[MagicMock, None, None]: """Return a mocked BSBLAN client.""" with ( patch("homeassistant.components.bsblan.BSBLAN", autospec=True) as bsblan_mock, @@ -52,7 +52,6 @@ def mock_bsblan() -> Generator[MagicMock]: load_fixture("device.json", DOMAIN) ) bsblan.state.return_value = State.from_json(load_fixture("state.json", DOMAIN)) - bsblan.static_values.return_value = StaticState.from_json( load_fixture("static.json", DOMAIN) ) diff --git a/tests/components/bsblan/fixtures/static_F.json b/tests/components/bsblan/fixtures/static_F.json new file mode 100644 index 00000000000000..a61e870f6e51ef --- /dev/null +++ b/tests/components/bsblan/fixtures/static_F.json @@ -0,0 +1,20 @@ +{ + "min_temp": { + "name": "Room temp frost protection setpoint", + "error": 0, + "value": "8.0", + "desc": "", + "dataType": 0, + "readonly": 0, + "unit": "°F" + }, + "max_temp": { + "name": "Summer/winter changeover temp heat circuit 1", + "error": 0, + "value": "20.0", + "desc": "", + "dataType": 0, + "readonly": 0, + "unit": "°F" + } +} diff --git a/tests/components/bsblan/snapshots/test_climate.ambr b/tests/components/bsblan/snapshots/test_climate.ambr new file mode 100644 index 00000000000000..4eb70fe2658152 --- /dev/null +++ b/tests/components/bsblan/snapshots/test_climate.ambr @@ -0,0 +1,220 @@ +# serializer version: 1 +# name: test_celsius_fahrenheit[static.json][climate.bsb_lan-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 20.0, + 'min_temp': 8.0, + '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.json][climate.bsb_lan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 18.6, + 'friendly_name': 'BSB-LAN', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 20.0, + 'min_temp': 8.0, + 'preset_mode': 'none', + 'preset_modes': list([ + 'eco', + 'none', + ]), + 'supported_features': , + 'temperature': 18.5, + }), + 'context': , + 'entity_id': 'climate.bsb_lan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + '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({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 20.0, + 'min_temp': 8.0, + '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_climate_entity_properties[climate.bsb_lan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 18.6, + 'friendly_name': 'BSB-LAN', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 20.0, + 'min_temp': 8.0, + 'preset_mode': 'none', + 'preset_modes': list([ + 'eco', + 'none', + ]), + 'supported_features': , + 'temperature': 18.5, + }), + 'context': , + 'entity_id': 'climate.bsb_lan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/bsblan/test_climate.py b/tests/components/bsblan/test_climate.py new file mode 100644 index 00000000000000..c519c3043da6f6 --- /dev/null +++ b/tests/components/bsblan/test_climate.py @@ -0,0 +1,307 @@ +"""Tests for the BSB-Lan climate platform.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock + +from bsblan import BSBLANError, StaticState +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, + DOMAIN as CLIMATE_DOMAIN, + PRESET_ECO, + PRESET_NONE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_TEMPERATURE, + HVACMode, +) +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, + load_json_object_fixture, + 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) + + +async def test_climate_entity_properties( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the 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_bsblan.state.return_value.target_temperature = mock_target_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["temperature"] == 23.5 + + # Test hvac_mode + mock_hvac_mode = MagicMock() + mock_hvac_mode.value = HVACMode.AUTO + mock_bsblan.state.return_value.hvac_mode = mock_hvac_mode + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.state == HVACMode.AUTO + + # Test preset_mode + mock_hvac_mode.value = PRESET_ECO + + 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["preset_mode"] == PRESET_ECO + + +@pytest.mark.parametrize( + "mode", + [HVACMode.HEAT, HVACMode.AUTO, HVACMode.OFF], +) +async def test_async_set_hvac_mode( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, + mode: HVACMode, +) -> None: + """Test setting HVAC mode via service call.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + # Call the service to set HVAC mode + await hass.services.async_call( + domain=CLIMATE_DOMAIN, + service=SERVICE_SET_HVAC_MODE, + service_data={ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: mode}, + blocking=True, + ) + + # Assert that the thermostat method was called + mock_bsblan.thermostat.assert_called_once_with(hvac_mode=mode) + mock_bsblan.thermostat.reset_mock() + + +@pytest.mark.parametrize( + ("hvac_mode", "preset_mode"), + [ + (HVACMode.AUTO, PRESET_ECO), + (HVACMode.AUTO, PRESET_NONE), + ], +) +async def test_async_set_preset_mode_succes( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, + hvac_mode: HVACMode, + preset_mode: str, +) -> None: + """Test setting preset mode via service call.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + # patch hvac_mode + mock_hvac_mode = MagicMock() + mock_hvac_mode.value = hvac_mode + mock_bsblan.state.return_value.hvac_mode = mock_hvac_mode + + # Attempt to set the preset mode + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: preset_mode}, + blocking=True, + ) + await hass.async_block_till_done() + + +@pytest.mark.parametrize( + ("hvac_mode", "preset_mode"), + [ + ( + HVACMode.HEAT, + PRESET_ECO, + ) + ], +) +async def test_async_set_preset_mode_error( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, + hvac_mode: HVACMode, + preset_mode: str, +) -> None: + """Test setting preset mode via service call.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + # patch hvac_mode + mock_hvac_mode = MagicMock() + mock_hvac_mode.value = hvac_mode + mock_bsblan.state.return_value.hvac_mode = mock_hvac_mode + + # Attempt to set the preset mode + error_message = "Preset mode can only be set when HVAC mode is set to 'auto'" + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: preset_mode}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("target_temp"), + [ + (8.0), # Min temperature + (15.0), # Mid-range temperature + (20.0), # Max temperature + ], +) +async def test_async_set_temperature( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, + target_temp: float, +) -> None: + """Test setting temperature via service call.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + await hass.services.async_call( + domain=CLIMATE_DOMAIN, + service=SERVICE_SET_TEMPERATURE, + service_data={ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: target_temp}, + blocking=True, + ) + # Assert that the thermostat method was called with the correct temperature + mock_bsblan.thermostat.assert_called_once_with(target_temperature=target_temp) + + +async def test_async_set_data( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting data via service calls.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + # Test setting temperature + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 19}, + blocking=True, + ) + mock_bsblan.thermostat.assert_called_once_with(target_temperature=19) + mock_bsblan.thermostat.reset_mock() + + # Test setting HVAC mode + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.HEAT}, + blocking=True, + ) + mock_bsblan.thermostat.assert_called_once_with(hvac_mode=HVACMode.HEAT) + mock_bsblan.thermostat.reset_mock() + + # Patch HVAC mode to AUTO + mock_hvac_mode = MagicMock() + mock_hvac_mode.value = HVACMode.AUTO + mock_bsblan.state.return_value.hvac_mode = mock_hvac_mode + + # Test setting preset mode to ECO + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_ECO}, + blocking=True, + ) + mock_bsblan.thermostat.assert_called_once_with(hvac_mode=PRESET_ECO) + mock_bsblan.thermostat.reset_mock() + + # Test setting preset mode to NONE + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_NONE}, + blocking=True, + ) + mock_bsblan.thermostat.assert_called_once() + mock_bsblan.thermostat.reset_mock() + + # Test error handling + mock_bsblan.thermostat.side_effect = BSBLANError("Test error") + error_message = "An error occurred while updating the BSBLAN device" + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 20}, + blocking=True, + ) diff --git a/tests/components/bsblan/test_diagnostics.py b/tests/components/bsblan/test_diagnostics.py index 8939456c2ac012..aea53f8a1a2c60 100644 --- a/tests/components/bsblan/test_diagnostics.py +++ b/tests/components/bsblan/test_diagnostics.py @@ -1,5 +1,7 @@ """Tests for the diagnostics data provided by the BSBLan integration.""" +from unittest.mock import AsyncMock + from syrupy import SnapshotAssertion from homeassistant.core import HomeAssistant @@ -11,11 +13,13 @@ async def test_diagnostics( hass: HomeAssistant, + mock_bsblan: AsyncMock, hass_client: ClientSessionGenerator, init_integration: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" + diagnostics_data = await get_diagnostics_for_config_entry( hass, hass_client, init_integration )