diff --git a/homeassistant/components/fritzbox/const.py b/homeassistant/components/fritzbox/const.py index 7b67bcd6cf8..4831b0f6ab2 100644 --- a/homeassistant/components/fritzbox/const.py +++ b/homeassistant/components/fritzbox/const.py @@ -27,7 +27,8 @@ LOGGER: Final[logging.Logger] = logging.getLogger(__package__) PLATFORMS: Final[list[Platform]] = [ Platform.BINARY_SENSOR, Platform.CLIMATE, + Platform.COVER, Platform.LIGHT, - Platform.SWITCH, Platform.SENSOR, + Platform.SWITCH, ] diff --git a/homeassistant/components/fritzbox/cover.py b/homeassistant/components/fritzbox/cover.py new file mode 100644 index 00000000000..d8fc8d4f3c3 --- /dev/null +++ b/homeassistant/components/fritzbox/cover.py @@ -0,0 +1,78 @@ +"""Support for AVM FRITZ!SmartHome cover devices.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.cover import ( + ATTR_POSITION, + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import FritzBoxEntity +from .const import CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the FRITZ!SmartHome cover from ConfigEntry.""" + coordinator = hass.data[FRITZBOX_DOMAIN][entry.entry_id][CONF_COORDINATOR] + + async_add_entities( + FritzboxCover(coordinator, ain) + for ain, device in coordinator.data.items() + if device.has_blind + ) + + +class FritzboxCover(FritzBoxEntity, CoverEntity): + """The cover class for FRITZ!SmartHome covers.""" + + _attr_device_class = CoverDeviceClass.BLIND + _attr_supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + ) + + @property + def current_cover_position(self) -> int | None: + """Return the current position.""" + position = None + if self.device.levelpercentage is not None: + position = 100 - self.device.levelpercentage + return position + + @property + def is_closed(self) -> bool | None: + """Return if the cover is closed.""" + if self.device.levelpercentage is None: + return None + return self.device.levelpercentage == 100 # type: ignore [no-any-return] + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + await self.hass.async_add_executor_job(self.device.set_blind_open) + await self.coordinator.async_refresh() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + await self.hass.async_add_executor_job(self.device.set_blind_close) + await self.coordinator.async_refresh() + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + await self.hass.async_add_executor_job( + self.device.set_level_percentage, 100 - kwargs[ATTR_POSITION] + ) + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + await self.hass.async_add_executor_job(self.device.set_blind_stop) + await self.coordinator.async_refresh() diff --git a/homeassistant/components/fritzbox/manifest.json b/homeassistant/components/fritzbox/manifest.json index 1dac4ddd78a..710f7e8f0c4 100644 --- a/homeassistant/components/fritzbox/manifest.json +++ b/homeassistant/components/fritzbox/manifest.json @@ -2,7 +2,7 @@ "domain": "fritzbox", "name": "AVM FRITZ!SmartHome", "documentation": "https://www.home-assistant.io/integrations/fritzbox", - "requirements": ["pyfritzhome==0.6.4"], + "requirements": ["pyfritzhome==0.6.5"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:fritzbox:1" diff --git a/requirements_all.txt b/requirements_all.txt index 5388db5998b..184c6e4d77e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1536,7 +1536,7 @@ pyforked-daapd==0.1.11 pyfreedompro==1.1.0 # homeassistant.components.fritzbox -pyfritzhome==0.6.4 +pyfritzhome==0.6.5 # homeassistant.components.fronius pyfronius==0.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9baff4f1dbd..e8e7774d313 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1061,7 +1061,7 @@ pyforked-daapd==0.1.11 pyfreedompro==1.1.0 # homeassistant.components.fritzbox -pyfritzhome==0.6.4 +pyfritzhome==0.6.5 # homeassistant.components.fronius pyfronius==0.7.1 diff --git a/tests/components/fritzbox/__init__.py b/tests/components/fritzbox/__init__.py index 05003ccdf51..05cd60059fa 100644 --- a/tests/components/fritzbox/__init__.py +++ b/tests/components/fritzbox/__init__.py @@ -61,6 +61,7 @@ class FritzDeviceBinarySensorMock(FritzDeviceBaseMock): has_lightbulb = False has_temperature_sensor = False has_thermostat = False + has_blind = False present = True @@ -82,6 +83,7 @@ class FritzDeviceClimateMock(FritzDeviceBaseMock): has_switch = False has_temperature_sensor = True has_thermostat = True + has_blind = False holiday_active = "fake_holiday" lock = "fake_locked" present = True @@ -106,6 +108,7 @@ class FritzDeviceSensorMock(FritzDeviceBaseMock): has_switch = False has_temperature_sensor = True has_thermostat = False + has_blind = False lock = "fake_locked" present = True temperature = 1.23 @@ -126,6 +129,7 @@ class FritzDeviceSwitchMock(FritzDeviceBaseMock): has_switch = True has_temperature_sensor = True has_thermostat = False + has_blind = False switch_state = "fake_state" lock = "fake_locked" power = 5678 @@ -143,6 +147,21 @@ class FritzDeviceLightMock(FritzDeviceBaseMock): has_switch = False has_temperature_sensor = False has_thermostat = False + has_blind = False level = 100 present = True state = True + + +class FritzDeviceCoverMock(FritzDeviceBaseMock): + """Mock of a AVM Fritz!Box cover device.""" + + fw_version = "1.2.3" + has_alarm = False + has_powermeter = False + has_lightbulb = False + has_switch = False + has_temperature_sensor = False + has_thermostat = False + has_blind = True + levelpercentage = 0 diff --git a/tests/components/fritzbox/test_cover.py b/tests/components/fritzbox/test_cover.py new file mode 100644 index 00000000000..07b6ab5990e --- /dev/null +++ b/tests/components/fritzbox/test_cover.py @@ -0,0 +1,86 @@ +"""Tests for AVM Fritz!Box switch component.""" +from unittest.mock import Mock, call + +from homeassistant.components.cover import ATTR_CURRENT_POSITION, ATTR_POSITION, DOMAIN +from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_DEVICES, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_SET_COVER_POSITION, + SERVICE_STOP_COVER, +) +from homeassistant.core import HomeAssistant + +from . import FritzDeviceCoverMock, setup_config_entry +from .const import CONF_FAKE_NAME, MOCK_CONFIG + +ENTITY_ID = f"{DOMAIN}.{CONF_FAKE_NAME}" + + +async def test_setup(hass: HomeAssistant, fritz: Mock): + """Test setup of platform.""" + device = FritzDeviceCoverMock() + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + state = hass.states.get(ENTITY_ID) + assert state + assert state.attributes[ATTR_CURRENT_POSITION] == 100 + + +async def test_open_cover(hass: HomeAssistant, fritz: Mock): + """Test opening the cover.""" + device = FritzDeviceCoverMock() + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + assert await hass.services.async_call( + DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: ENTITY_ID}, True + ) + assert device.set_blind_open.call_count == 1 + + +async def test_close_cover(hass: HomeAssistant, fritz: Mock): + """Test closing the device.""" + device = FritzDeviceCoverMock() + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + assert await hass.services.async_call( + DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: ENTITY_ID}, True + ) + assert device.set_blind_close.call_count == 1 + + +async def test_set_position_cover(hass: HomeAssistant, fritz: Mock): + """Test stopping the device.""" + device = FritzDeviceCoverMock() + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + assert await hass.services.async_call( + DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_POSITION: 50}, + True, + ) + assert device.set_level_percentage.call_args_list == [call(50)] + + +async def test_stop_cover(hass: HomeAssistant, fritz: Mock): + """Test stopping the device.""" + device = FritzDeviceCoverMock() + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + assert await hass.services.async_call( + DOMAIN, SERVICE_STOP_COVER, {ATTR_ENTITY_ID: ENTITY_ID}, True + ) + assert device.set_blind_stop.call_count == 1