diff --git a/homeassistant/components/flexit_bacnet/__init__.py b/homeassistant/components/flexit_bacnet/__init__.py index 27800af6626..ba7134d7e50 100644 --- a/homeassistant/components/flexit_bacnet/__init__.py +++ b/homeassistant/components/flexit_bacnet/__init__.py @@ -8,7 +8,12 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN from .coordinator import FlexitCoordinator -PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.SENSOR] +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.CLIMATE, + Platform.SENSOR, + Platform.SWITCH, +] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/flexit_bacnet/strings.json b/homeassistant/components/flexit_bacnet/strings.json index aeb349dd1d4..d9efd1fc411 100644 --- a/homeassistant/components/flexit_bacnet/strings.json +++ b/homeassistant/components/flexit_bacnet/strings.json @@ -68,6 +68,11 @@ "heat_exchanger_speed": { "name": "Heat exchanger speed" } + }, + "switch": { + "electric_heater": { + "name": "Electric heater" + } } } } diff --git a/homeassistant/components/flexit_bacnet/switch.py b/homeassistant/components/flexit_bacnet/switch.py new file mode 100644 index 00000000000..151bd9d96ec --- /dev/null +++ b/homeassistant/components/flexit_bacnet/switch.py @@ -0,0 +1,96 @@ +"""The Flexit Nordic (BACnet) integration.""" +import asyncio.exceptions +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from flexit_bacnet import FlexitBACnet +from flexit_bacnet.bacnet import DecodingError + +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import FlexitCoordinator +from .const import DOMAIN +from .entity import FlexitEntity + + +@dataclass(kw_only=True, frozen=True) +class FlexitSwitchEntityDescription(SwitchEntityDescription): + """Describes a Flexit switch entity.""" + + is_on_fn: Callable[[FlexitBACnet], bool] + + +SWITCHES: tuple[FlexitSwitchEntityDescription, ...] = ( + FlexitSwitchEntityDescription( + key="electric_heater", + translation_key="electric_heater", + icon="mdi:radiator", + is_on_fn=lambda data: data.electric_heater, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Flexit (bacnet) switch from a config entry.""" + coordinator: FlexitCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + FlexitSwitch(coordinator, description) for description in SWITCHES + ) + + +class FlexitSwitch(FlexitEntity, SwitchEntity): + """Representation of a Flexit Switch.""" + + _attr_device_class = SwitchDeviceClass.SWITCH + + entity_description: FlexitSwitchEntityDescription + + def __init__( + self, + coordinator: FlexitCoordinator, + entity_description: FlexitSwitchEntityDescription, + ) -> None: + """Initialize Flexit (bacnet) switch.""" + super().__init__(coordinator) + + self.entity_description = entity_description + self._attr_unique_id = ( + f"{coordinator.device.serial_number}-{entity_description.key}" + ) + + @property + def is_on(self) -> bool: + """Return value of the switch.""" + return self.entity_description.is_on_fn(self.coordinator.data) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn electric heater on.""" + try: + await self.device.enable_electric_heater() + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: + raise HomeAssistantError from exc + finally: + await self.coordinator.async_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn electric heater off.""" + try: + await self.device.disable_electric_heater() + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: + raise HomeAssistantError from exc + finally: + await self.coordinator.async_refresh() diff --git a/tests/components/flexit_bacnet/conftest.py b/tests/components/flexit_bacnet/conftest.py index f0117b41536..c192489805f 100644 --- a/tests/components/flexit_bacnet/conftest.py +++ b/tests/components/flexit_bacnet/conftest.py @@ -60,6 +60,7 @@ def mock_flexit_bacnet() -> Generator[AsyncMock, None, None]: flexit_bacnet.heat_exchanger_efficiency = 81 flexit_bacnet.heat_exchanger_speed = 100 flexit_bacnet.air_filter_polluted = False + flexit_bacnet.electric_heater = True yield flexit_bacnet diff --git a/tests/components/flexit_bacnet/snapshots/test_switch.ambr b/tests/components/flexit_bacnet/snapshots/test_switch.ambr new file mode 100644 index 00000000000..4db770917b0 --- /dev/null +++ b/tests/components/flexit_bacnet/snapshots/test_switch.ambr @@ -0,0 +1,60 @@ +# serializer version: 1 +# name: test_switches[switch.device_name_electric_heater-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.device_name_electric_heater', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:radiator', + 'original_name': 'Electric heater', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'electric_heater', + 'unique_id': '0000-0001-electric_heater', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.device_name_electric_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Device Name Electric heater', + 'icon': 'mdi:radiator', + }), + 'context': , + 'entity_id': 'switch.device_name_electric_heater', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches_implementation[switch.device_name_electric_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Device Name Electric heater', + 'icon': 'mdi:radiator', + }), + 'context': , + 'entity_id': 'switch.device_name_electric_heater', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/flexit_bacnet/test_switch.py b/tests/components/flexit_bacnet/test_switch.py new file mode 100644 index 00000000000..7c08fc2a024 --- /dev/null +++ b/tests/components/flexit_bacnet/test_switch.py @@ -0,0 +1,135 @@ +"""Tests for the Flexit Nordic (BACnet) switch entities.""" +from unittest.mock import AsyncMock + +from flexit_bacnet import DecodingError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry +from tests.components.flexit_bacnet import setup_with_selected_platforms + +ENTITY_ID = "switch.device_name_electric_heater" + + +async def test_switches( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_flexit_bacnet: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test switch states are correctly collected from library.""" + + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SWITCH]) + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + assert entity_entries + for entity_entry in entity_entries: + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert (state := hass.states.get(entity_entry.entity_id)) + assert state == snapshot(name=f"{entity_entry.entity_id}-state") + + +async def test_switches_implementation( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_flexit_bacnet: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the switch can be turned on and off.""" + + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SWITCH]) + assert hass.states.get(ENTITY_ID) == snapshot(name=f"{ENTITY_ID}-state") + + # Set to off + mock_flexit_bacnet.electric_heater = False + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + mocked_method = getattr(mock_flexit_bacnet, "disable_electric_heater") + assert len(mocked_method.mock_calls) == 1 + assert hass.states.get(ENTITY_ID).state == STATE_OFF + + # Set to on + mock_flexit_bacnet.electric_heater = True + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + mocked_method = getattr(mock_flexit_bacnet, "enable_electric_heater") + assert len(mocked_method.mock_calls) == 1 + assert hass.states.get(ENTITY_ID).state == STATE_ON + + # Error recovery, when turning off + mock_flexit_bacnet.disable_electric_heater.side_effect = DecodingError + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + mocked_method = getattr(mock_flexit_bacnet, "disable_electric_heater") + assert len(mocked_method.mock_calls) == 2 + + mock_flexit_bacnet.disable_electric_heater.side_effect = None + mock_flexit_bacnet.electric_heater = False + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + assert hass.states.get(ENTITY_ID).state == STATE_OFF + + # Error recovery, when turning on + mock_flexit_bacnet.enable_electric_heater.side_effect = DecodingError + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + mocked_method = getattr(mock_flexit_bacnet, "enable_electric_heater") + assert len(mocked_method.mock_calls) == 2 + + mock_flexit_bacnet.enable_electric_heater.side_effect = None + mock_flexit_bacnet.electric_heater = True + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + assert hass.states.get(ENTITY_ID).state == STATE_ON