diff --git a/homeassistant/components/somfy/__init__.py b/homeassistant/components/somfy/__init__.py index cc7499c3492..71d7f7f790c 100644 --- a/homeassistant/components/somfy/__init__.py +++ b/homeassistant/components/somfy/__init__.py @@ -1,5 +1,4 @@ """Support for Somfy hubs.""" -from abc import abstractmethod from datetime import timedelta import logging @@ -8,20 +7,16 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_OPTIMISTIC -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers import ( config_entry_oauth2_flow, config_validation as cv, device_registry as dr, ) -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) from . import api, config_flow -from .const import API, COORDINATOR, DOMAIN +from .const import COORDINATOR, DOMAIN +from .coordinator import SomfyDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -84,25 +79,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) data = hass.data[DOMAIN] - data[API] = api.ConfigEntrySomfyApi(hass, entry, implementation) - - async def _update_all_devices(): - """Update all the devices.""" - devices = await hass.async_add_executor_job(data[API].get_devices) - previous_devices = data[COORDINATOR].data - # Sometimes Somfy returns an empty list. - if not devices and previous_devices: - _LOGGER.debug( - "No devices returned. Assuming the previous ones are still valid" - ) - return previous_devices - return {dev.id: dev for dev in devices} - - coordinator = DataUpdateCoordinator( + coordinator = SomfyDataUpdateCoordinator( hass, _LOGGER, name="somfy device update", - update_method=_update_all_devices, + client=api.ConfigEntrySomfyApi(hass, entry, implementation), update_interval=SCAN_INTERVAL, ) data[COORDINATOR] = coordinator @@ -140,70 +121,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): """Unload a config entry.""" - hass.data[DOMAIN].pop(API, None) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -class SomfyEntity(CoordinatorEntity, Entity): - """Representation of a generic Somfy device.""" - - def __init__(self, coordinator, device_id, somfy_api): - """Initialize the Somfy device.""" - super().__init__(coordinator) - self._id = device_id - self.api = somfy_api - - @property - def device(self): - """Return data for the device id.""" - return self.coordinator.data[self._id] - - @property - def unique_id(self) -> str: - """Return the unique id base on the id returned by Somfy.""" - return self._id - - @property - def name(self) -> str: - """Return the name of the device.""" - return self.device.name - - @property - def device_info(self): - """Return device specific attributes. - - Implemented by platform classes. - """ - return { - "identifiers": {(DOMAIN, self.unique_id)}, - "name": self.name, - "model": self.device.type, - "via_device": (DOMAIN, self.device.parent_id), - # For the moment, Somfy only returns their own device. - "manufacturer": "Somfy", - } - - def has_capability(self, capability: str) -> bool: - """Test if device has a capability.""" - capabilities = self.device.capabilities - return bool([c for c in capabilities if c.name == capability]) - - def has_state(self, state: str) -> bool: - """Test if device has a state.""" - states = self.device.states - return bool([c for c in states if c.name == state]) - - @property - def assumed_state(self) -> bool: - """Return if the device has an assumed state.""" - return not bool(self.device.states) - - @callback - def _handle_coordinator_update(self): - """Process an update from the coordinator.""" - self._create_device() - super()._handle_coordinator_update() - - @abstractmethod - def _create_device(self): - """Update the device with the latest data.""" diff --git a/homeassistant/components/somfy/climate.py b/homeassistant/components/somfy/climate.py index 66602aea3e6..0963321100c 100644 --- a/homeassistant/components/somfy/climate.py +++ b/homeassistant/components/somfy/climate.py @@ -23,8 +23,8 @@ from homeassistant.components.climate.const import ( ) from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS -from . import SomfyEntity -from .const import API, COORDINATOR, DOMAIN +from .const import COORDINATOR, DOMAIN +from .entity import SomfyEntity SUPPORTED_CATEGORIES = {Category.HVAC.value} @@ -49,10 +49,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Somfy climate platform.""" domain_data = hass.data[DOMAIN] coordinator = domain_data[COORDINATOR] - api = domain_data[API] climates = [ - SomfyClimate(coordinator, device_id, api) + SomfyClimate(coordinator, device_id) for device_id, device in coordinator.data.items() if SUPPORTED_CATEGORIES & set(device.categories) ] @@ -63,15 +62,15 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class SomfyClimate(SomfyEntity, ClimateEntity): """Representation of a Somfy thermostat device.""" - def __init__(self, coordinator, device_id, api): + def __init__(self, coordinator, device_id): """Initialize the Somfy device.""" - super().__init__(coordinator, device_id, api) + super().__init__(coordinator, device_id) self._climate = None self._create_device() def _create_device(self): """Update the device with the latest data.""" - self._climate = Thermostat(self.device, self.api) + self._climate = Thermostat(self.device, self.coordinator.client) @property def supported_features(self) -> int: diff --git a/homeassistant/components/somfy/const.py b/homeassistant/components/somfy/const.py index 128d6eb76bb..6c7c23e3ab3 100644 --- a/homeassistant/components/somfy/const.py +++ b/homeassistant/components/somfy/const.py @@ -2,4 +2,3 @@ DOMAIN = "somfy" COORDINATOR = "coordinator" -API = "api" diff --git a/homeassistant/components/somfy/coordinator.py b/homeassistant/components/somfy/coordinator.py new file mode 100644 index 00000000000..c9633c4fa4d --- /dev/null +++ b/homeassistant/components/somfy/coordinator.py @@ -0,0 +1,71 @@ +"""Helpers to help coordinate updated.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from pymfy.api.error import QuotaViolationException, SetupNotFoundException +from pymfy.api.model import Device +from pymfy.api.somfy_api import SomfyApi + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + + +class SomfyDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching Somfy data.""" + + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + *, + name: str, + client: SomfyApi, + update_interval: timedelta | None = None, + ) -> None: + """Initialize global data updater.""" + super().__init__( + hass, + logger, + name=name, + update_interval=update_interval, + ) + self.data = {} + self.client = client + self.site_device = {} + self.last_site_index = -1 + + async def _async_update_data(self) -> dict[str, Device]: + """Fetch Somfy data. + + Somfy only allow one call per minute to /site. There is one exception: 2 calls are allowed after site retrieval. + """ + if not self.site_device: + sites = await self.hass.async_add_executor_job(self.client.get_sites) + if not sites: + return {} + self.site_device = {site.id: [] for site in sites} + + site_id = self._site_id + try: + devices = await self.hass.async_add_executor_job( + self.client.get_devices, site_id + ) + self.site_device[site_id] = devices + except SetupNotFoundException: + del self.site_device[site_id] + return await self._async_update_data() + except QuotaViolationException: + self.logger.warning("Quota violation") + + return {dev.id: dev for devices in self.site_device.values() for dev in devices} + + @property + def _site_id(self): + """Return the next site id to retrieve. + + This tweak is required as Somfy does not allow to call the /site entrypoint more than once per minute. + """ + self.last_site_index = (self.last_site_index + 1) % len(self.site_device) + return list(self.site_device.keys())[self.last_site_index] diff --git a/homeassistant/components/somfy/cover.py b/homeassistant/components/somfy/cover.py index d227bc31227..8ed06b3bcd7 100644 --- a/homeassistant/components/somfy/cover.py +++ b/homeassistant/components/somfy/cover.py @@ -21,8 +21,8 @@ from homeassistant.components.cover import ( from homeassistant.const import CONF_OPTIMISTIC, STATE_CLOSED, STATE_OPEN from homeassistant.helpers.restore_state import RestoreEntity -from . import SomfyEntity -from .const import API, COORDINATOR, DOMAIN +from .const import COORDINATOR, DOMAIN +from .entity import SomfyEntity BLIND_DEVICE_CATEGORIES = {Category.INTERIOR_BLIND.value, Category.EXTERIOR_BLIND.value} SHUTTER_DEVICE_CATEGORIES = {Category.EXTERIOR_BLIND.value} @@ -37,10 +37,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Somfy cover platform.""" domain_data = hass.data[DOMAIN] coordinator = domain_data[COORDINATOR] - api = domain_data[API] covers = [ - SomfyCover(coordinator, device_id, api, domain_data[CONF_OPTIMISTIC]) + SomfyCover(coordinator, device_id, domain_data[CONF_OPTIMISTIC]) for device_id, device in coordinator.data.items() if SUPPORTED_CATEGORIES & set(device.categories) ] @@ -51,9 +50,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class SomfyCover(SomfyEntity, RestoreEntity, CoverEntity): """Representation of a Somfy cover device.""" - def __init__(self, coordinator, device_id, api, optimistic): + def __init__(self, coordinator, device_id, optimistic): """Initialize the Somfy device.""" - super().__init__(coordinator, device_id, api) + super().__init__(coordinator, device_id) self.categories = set(self.device.categories) self.optimistic = optimistic self._closed = None @@ -64,7 +63,7 @@ class SomfyCover(SomfyEntity, RestoreEntity, CoverEntity): def _create_device(self) -> Blind: """Update the device with the latest data.""" - self._cover = Blind(self.device, self.api) + self._cover = Blind(self.device, self.coordinator.client) @property def supported_features(self) -> int: diff --git a/homeassistant/components/somfy/entity.py b/homeassistant/components/somfy/entity.py new file mode 100644 index 00000000000..88ff86e8849 --- /dev/null +++ b/homeassistant/components/somfy/entity.py @@ -0,0 +1,73 @@ +"""Entity representing a Somfy device.""" + +from abc import abstractmethod + +from homeassistant.core import callback +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN + + +class SomfyEntity(CoordinatorEntity, Entity): + """Representation of a generic Somfy device.""" + + def __init__(self, coordinator, device_id): + """Initialize the Somfy device.""" + super().__init__(coordinator) + self._id = device_id + + @property + def device(self): + """Return data for the device id.""" + return self.coordinator.data[self._id] + + @property + def unique_id(self) -> str: + """Return the unique id base on the id returned by Somfy.""" + return self._id + + @property + def name(self) -> str: + """Return the name of the device.""" + return self.device.name + + @property + def device_info(self): + """Return device specific attributes. + + Implemented by platform classes. + """ + return { + "identifiers": {(DOMAIN, self.unique_id)}, + "name": self.name, + "model": self.device.type, + "via_device": (DOMAIN, self.device.parent_id), + # For the moment, Somfy only returns their own device. + "manufacturer": "Somfy", + } + + def has_capability(self, capability: str) -> bool: + """Test if device has a capability.""" + capabilities = self.device.capabilities + return bool([c for c in capabilities if c.name == capability]) + + def has_state(self, state: str) -> bool: + """Test if device has a state.""" + states = self.device.states + return bool([c for c in states if c.name == state]) + + @property + def assumed_state(self) -> bool: + """Return if the device has an assumed state.""" + return not bool(self.device.states) + + @callback + def _handle_coordinator_update(self): + """Process an update from the coordinator.""" + self._create_device() + super()._handle_coordinator_update() + + @abstractmethod + def _create_device(self): + """Update the device with the latest data.""" diff --git a/homeassistant/components/somfy/manifest.json b/homeassistant/components/somfy/manifest.json index 8dad4abd6cc..1adbab49fb2 100644 --- a/homeassistant/components/somfy/manifest.json +++ b/homeassistant/components/somfy/manifest.json @@ -5,7 +5,7 @@ "documentation": "https://www.home-assistant.io/integrations/somfy", "dependencies": ["http"], "codeowners": ["@tetienne"], - "requirements": ["pymfy==0.9.3"], + "requirements": ["pymfy==0.11.0"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/homeassistant/components/somfy/sensor.py b/homeassistant/components/somfy/sensor.py index 312c425cf87..1817ba3fd8c 100644 --- a/homeassistant/components/somfy/sensor.py +++ b/homeassistant/components/somfy/sensor.py @@ -6,8 +6,8 @@ from pymfy.api.devices.thermostat import Thermostat from homeassistant.components.sensor import SensorEntity from homeassistant.const import DEVICE_CLASS_BATTERY, PERCENTAGE -from . import SomfyEntity -from .const import API, COORDINATOR, DOMAIN +from .const import COORDINATOR, DOMAIN +from .entity import SomfyEntity SUPPORTED_CATEGORIES = {Category.HVAC.value} @@ -16,10 +16,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Somfy sensor platform.""" domain_data = hass.data[DOMAIN] coordinator = domain_data[COORDINATOR] - api = domain_data[API] sensors = [ - SomfyThermostatBatterySensor(coordinator, device_id, api) + SomfyThermostatBatterySensor(coordinator, device_id) for device_id, device in coordinator.data.items() if SUPPORTED_CATEGORIES & set(device.categories) ] @@ -33,15 +32,15 @@ class SomfyThermostatBatterySensor(SomfyEntity, SensorEntity): _attr_device_class = DEVICE_CLASS_BATTERY _attr_unit_of_measurement = PERCENTAGE - def __init__(self, coordinator, device_id, api): + def __init__(self, coordinator, device_id): """Initialize the Somfy device.""" - super().__init__(coordinator, device_id, api) + super().__init__(coordinator, device_id) self._climate = None self._create_device() def _create_device(self): """Update the device with the latest data.""" - self._climate = Thermostat(self.device, self.api) + self._climate = Thermostat(self.device, self.coordinator.client) @property def state(self) -> int: diff --git a/homeassistant/components/somfy/switch.py b/homeassistant/components/somfy/switch.py index 66eef99d6b5..bd0b1dce5d5 100644 --- a/homeassistant/components/somfy/switch.py +++ b/homeassistant/components/somfy/switch.py @@ -4,18 +4,17 @@ from pymfy.api.devices.category import Category from homeassistant.components.switch import SwitchEntity -from . import SomfyEntity -from .const import API, COORDINATOR, DOMAIN +from .const import COORDINATOR, DOMAIN +from .entity import SomfyEntity async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Somfy switch platform.""" domain_data = hass.data[DOMAIN] coordinator = domain_data[COORDINATOR] - api = domain_data[API] switches = [ - SomfyCameraShutter(coordinator, device_id, api) + SomfyCameraShutter(coordinator, device_id) for device_id, device in coordinator.data.items() if Category.CAMERA.value in device.categories ] @@ -26,14 +25,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class SomfyCameraShutter(SomfyEntity, SwitchEntity): """Representation of a Somfy Camera Shutter device.""" - def __init__(self, coordinator, device_id, api): + def __init__(self, coordinator, device_id): """Initialize the Somfy device.""" - super().__init__(coordinator, device_id, api) + super().__init__(coordinator, device_id) self._create_device() def _create_device(self): """Update the device with the latest data.""" - self.shutter = CameraProtect(self.device, self.api) + self.shutter = CameraProtect(self.device, self.coordinator.client) def turn_on(self, **kwargs) -> None: """Turn the entity on.""" diff --git a/requirements_all.txt b/requirements_all.txt index 80b97205115..65858084e5f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1582,7 +1582,7 @@ pymelcloud==2.5.3 pymeteoclimatic==0.0.6 # homeassistant.components.somfy -pymfy==0.9.3 +pymfy==0.11.0 # homeassistant.components.xiaomi_tv pymitv==1.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 306c158d627..fae02a7c4f5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -896,7 +896,7 @@ pymelcloud==2.5.3 pymeteoclimatic==0.0.6 # homeassistant.components.somfy -pymfy==0.9.3 +pymfy==0.11.0 # homeassistant.components.mochad pymochad==0.2.0 diff --git a/tests/components/somfy/test_config_flow.py b/tests/components/somfy/test_config_flow.py index b7d78883706..6a1c32e4138 100644 --- a/tests/components/somfy/test_config_flow.py +++ b/tests/components/somfy/test_config_flow.py @@ -82,7 +82,9 @@ async def test_full_flow( }, ) - with patch("homeassistant.components.somfy.api.ConfigEntrySomfyApi"): + with patch( + "homeassistant.components.somfy.async_setup_entry", return_value=True + ) as mock_setup_entry: result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["data"]["auth_implementation"] == DOMAIN @@ -95,12 +97,7 @@ async def test_full_flow( "expires_in": 60, } - assert DOMAIN in hass.config.components - entry = hass.config_entries.async_entries(DOMAIN)[0] - assert entry.state is config_entries.ConfigEntryState.LOADED - - assert await hass.config_entries.async_unload(entry.entry_id) - assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + assert len(mock_setup_entry.mock_calls) == 1 async def test_abort_if_authorization_timeout(hass, current_request_with_host):