From 6c01ed8d9780daa87b88f1a93a2d67d5d2a47bdd Mon Sep 17 00:00:00 2001 From: Andre Richter Date: Sun, 24 Oct 2021 23:21:35 +0200 Subject: [PATCH] Use DataUpdateCoordinator in Vallox (#56966) --- homeassistant/components/vallox/__init__.py | 162 ++++++++++---------- homeassistant/components/vallox/const.py | 3 +- homeassistant/components/vallox/fan.py | 142 +++++++---------- homeassistant/components/vallox/sensor.py | 133 +++++----------- 4 files changed, 176 insertions(+), 264 deletions(-) diff --git a/homeassistant/components/vallox/__init__.py b/homeassistant/components/vallox/__init__.py index 620114863a8..3f441054fb1 100644 --- a/homeassistant/components/vallox/__init__.py +++ b/homeassistant/components/vallox/__init__.py @@ -1,7 +1,7 @@ """Support for Vallox ventilation units.""" from __future__ import annotations -from datetime import datetime +from dataclasses import dataclass, field import ipaddress import logging from typing import Any @@ -11,13 +11,12 @@ from vallox_websocket_api.constants import vlxDevConstants from vallox_websocket_api.exceptions import ValloxApiException import voluptuous as vol -from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STARTED from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType, StateType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( DEFAULT_FAN_SPEED_AWAY, @@ -28,8 +27,7 @@ from .const import ( METRIC_KEY_PROFILE_FAN_SPEED_AWAY, METRIC_KEY_PROFILE_FAN_SPEED_BOOST, METRIC_KEY_PROFILE_FAN_SPEED_HOME, - SIGNAL_VALLOX_STATE_UPDATE, - STATE_PROXY_SCAN_INTERVAL, + STATE_SCAN_INTERVAL, STR_TO_VALLOX_PROFILE_SETTABLE, ) @@ -91,6 +89,40 @@ SERVICE_TO_METHOD = { } +@dataclass +class ValloxState: + """Describes the current state of the unit.""" + + metric_cache: dict[str, Any] = field(default_factory=dict) + profile: VALLOX_PROFILE = VALLOX_PROFILE.NONE + + def get_metric(self, metric_key: str) -> StateType: + """Return cached state value.""" + _LOGGER.debug("Fetching metric key: %s", metric_key) + + if metric_key not in vlxDevConstants.__dict__: + _LOGGER.debug("Metric key invalid: %s", metric_key) + + if (value := self.metric_cache.get(metric_key)) is None: + return None + + if not isinstance(value, (str, int, float)): + _LOGGER.debug( + "Return value of metric %s has unexpected type %s", + metric_key, + type(value), + ) + return None + + return value + + +class ValloxDataUpdateCoordinator(DataUpdateCoordinator): + """The DataUpdateCoordinator for Vallox.""" + + data: ValloxState + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the client and boot the platforms.""" conf = config[DOMAIN] @@ -98,102 +130,74 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: name = conf.get(CONF_NAME) client = Vallox(host) - state_proxy = ValloxStateProxy(hass, client) - service_handler = ValloxServiceHandler(client, state_proxy) - hass.data[DOMAIN] = {"client": client, "state_proxy": state_proxy, "name": name} + async def async_update_data() -> ValloxState: + """Fetch state update.""" + _LOGGER.debug("Updating Vallox state cache") + try: + metric_cache = await client.fetch_metrics() + profile = await client.get_profile() + + except (OSError, ValloxApiException) as err: + raise UpdateFailed("Error during state cache update") from err + + return ValloxState(metric_cache, profile) + + coordinator = ValloxDataUpdateCoordinator( + hass, + _LOGGER, + name=f"{name} DataUpdateCoordinator", + update_interval=STATE_SCAN_INTERVAL, + update_method=async_update_data, + ) + + service_handler = ValloxServiceHandler(client, coordinator) for vallox_service, method in SERVICE_TO_METHOD.items(): schema = method["schema"] hass.services.async_register( DOMAIN, vallox_service, service_handler.async_handle, schema=schema ) - # The vallox hardware expects quite strict timings for websocket requests. Timings that machines - # with less processing power, like Raspberries, cannot live up to during the busy start phase of - # Home Asssistant. Hence, async_add_entities() for fan and sensor in respective code will be - # called with update_before_add=False to intentionally delay the first request, increasing - # chance that it is issued only when the machine is less busy again. - hass.async_create_task(async_load_platform(hass, "sensor", DOMAIN, {}, config)) - hass.async_create_task(async_load_platform(hass, "fan", DOMAIN, {}, config)) + hass.data[DOMAIN] = {"client": client, "coordinator": coordinator, "name": name} - async_track_time_interval(hass, state_proxy.async_update, STATE_PROXY_SCAN_INTERVAL) + async def _async_load_platform_delayed(*_: Any) -> None: + await coordinator.async_refresh() + hass.async_create_task(async_load_platform(hass, "sensor", DOMAIN, {}, config)) + hass.async_create_task(async_load_platform(hass, "fan", DOMAIN, {}, config)) + + # The Vallox hardware expects quite strict timings for websocket requests. Timings that machines + # with less processing power, like a Raspberry Pi, cannot live up to during the busy start phase + # of Home Asssistant. + # + # Hence, wait for the started event before doing a first data refresh and loading the platforms, + # because it usually means the system is less busy after the event and can now meet the + # websocket timing requirements. + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STARTED, _async_load_platform_delayed + ) return True -class ValloxStateProxy: - """Helper class to reduce websocket API calls.""" - - def __init__(self, hass: HomeAssistant, client: Vallox) -> None: - """Initialize the proxy.""" - self._hass = hass - self._client = client - self._metric_cache: dict[str, Any] = {} - self._profile = VALLOX_PROFILE.NONE - self._valid = False - - def fetch_metric(self, metric_key: str) -> StateType: - """Return cached state value.""" - _LOGGER.debug("Fetching metric key: %s", metric_key) - - if not self._valid: - raise OSError("Device state out of sync.") - - if metric_key not in vlxDevConstants.__dict__: - raise KeyError(f"Unknown metric key: {metric_key}") - - if (value := self._metric_cache[metric_key]) is None: - return None - - if not isinstance(value, (str, int, float)): - raise TypeError( - f"Return value of metric {metric_key} has unexpected type {type(value)}" - ) - - return value - - def get_profile(self) -> VALLOX_PROFILE: - """Return cached profile value.""" - _LOGGER.debug("Returning profile") - - if not self._valid: - raise OSError("Device state out of sync.") - - return self._profile - - async def async_update(self, time: datetime | None = None) -> None: - """Fetch state update.""" - _LOGGER.debug("Updating Vallox state cache") - - try: - self._metric_cache = await self._client.fetch_metrics() - self._profile = await self._client.get_profile() - - except (OSError, ValloxApiException) as err: - self._valid = False - _LOGGER.error("Error during state cache update: %s", err) - return - - self._valid = True - async_dispatcher_send(self._hass, SIGNAL_VALLOX_STATE_UPDATE) - - class ValloxServiceHandler: """Services implementation.""" - def __init__(self, client: Vallox, state_proxy: ValloxStateProxy) -> None: + def __init__( + self, client: Vallox, coordinator: DataUpdateCoordinator[ValloxState] + ) -> None: """Initialize the proxy.""" self._client = client - self._state_proxy = state_proxy + self._coordinator = coordinator async def async_set_profile(self, profile: str = "Home") -> bool: """Set the ventilation profile.""" _LOGGER.debug("Setting ventilation profile to: %s", profile) _LOGGER.warning( - "Attention: The service 'vallox.set_profile' is superseded by the 'fan.set_preset_mode' service." - "It will be removed in the future, please migrate to 'fan.set_preset_mode' to prevent breakage" + "Attention: The service 'vallox.set_profile' is superseded by the " + "'fan.set_preset_mode' service. It will be removed in the future, please migrate to " + "'fan.set_preset_mode' to prevent breakage" ) try: @@ -269,4 +273,4 @@ class ValloxServiceHandler: # This state change affects other entities like sensors. Force an immediate update that can # be observed by all parties involved. if result: - await self._state_proxy.async_update() + await self._coordinator.async_request_refresh() diff --git a/homeassistant/components/vallox/const.py b/homeassistant/components/vallox/const.py index 6a9c4ddc5f4..6d12d0bab8e 100644 --- a/homeassistant/components/vallox/const.py +++ b/homeassistant/components/vallox/const.py @@ -7,8 +7,7 @@ from vallox_websocket_api import PROFILE as VALLOX_PROFILE DOMAIN = "vallox" DEFAULT_NAME = "Vallox" -SIGNAL_VALLOX_STATE_UPDATE = "vallox_state_update" -STATE_PROXY_SCAN_INTERVAL = timedelta(seconds=60) +STATE_SCAN_INTERVAL = timedelta(seconds=60) # Common metric keys and (default) values. METRIC_KEY_MODE = "A_CYC_MODE" diff --git a/homeassistant/components/vallox/fan.py b/homeassistant/components/vallox/fan.py index 8ee1b8b471f..39242b01b4e 100644 --- a/homeassistant/components/vallox/fan.py +++ b/homeassistant/components/vallox/fan.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Mapping import logging -from typing import Any +from typing import Any, NamedTuple from vallox_websocket_api import Vallox from vallox_websocket_api.exceptions import ValloxApiException @@ -13,12 +13,12 @@ from homeassistant.components.fan import ( FanEntity, NotValidPresetModeError, ) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import ValloxStateProxy +from . import ValloxDataUpdateCoordinator from .const import ( DOMAIN, METRIC_KEY_MODE, @@ -27,25 +27,38 @@ from .const import ( METRIC_KEY_PROFILE_FAN_SPEED_HOME, MODE_OFF, MODE_ON, - SIGNAL_VALLOX_STATE_UPDATE, STR_TO_VALLOX_PROFILE_SETTABLE, VALLOX_PROFILE_TO_STR_SETTABLE, ) _LOGGER = logging.getLogger(__name__) -ATTR_PROFILE_FAN_SPEED_HOME = { - "description": "fan_speed_home", - "metric_key": METRIC_KEY_PROFILE_FAN_SPEED_HOME, -} -ATTR_PROFILE_FAN_SPEED_AWAY = { - "description": "fan_speed_away", - "metric_key": METRIC_KEY_PROFILE_FAN_SPEED_AWAY, -} -ATTR_PROFILE_FAN_SPEED_BOOST = { - "description": "fan_speed_boost", - "metric_key": METRIC_KEY_PROFILE_FAN_SPEED_BOOST, -} + +class ExtraStateAttributeDetails(NamedTuple): + """Extra state attribute properties.""" + + description: str + metric_key: str + + +EXTRA_STATE_ATTRIBUTES = ( + ExtraStateAttributeDetails( + description="fan_speed_home", metric_key=METRIC_KEY_PROFILE_FAN_SPEED_HOME + ), + ExtraStateAttributeDetails( + description="fan_speed_away", metric_key=METRIC_KEY_PROFILE_FAN_SPEED_AWAY + ), + ExtraStateAttributeDetails( + description="fan_speed_boost", metric_key=METRIC_KEY_PROFILE_FAN_SPEED_BOOST + ), +) + + +def _convert_fan_speed_value(value: StateType) -> int | None: + if isinstance(value, (int, float)): + return int(value) + + return None async def async_setup_platform( @@ -62,31 +75,29 @@ async def async_setup_platform( client.set_settable_address(METRIC_KEY_MODE, int) device = ValloxFan( - hass.data[DOMAIN]["name"], client, hass.data[DOMAIN]["state_proxy"] + hass.data[DOMAIN]["name"], client, hass.data[DOMAIN]["coordinator"] ) - async_add_entities([device], update_before_add=False) + async_add_entities([device]) -class ValloxFan(FanEntity): +class ValloxFan(CoordinatorEntity, FanEntity): """Representation of the fan.""" - _attr_should_poll = False + coordinator: ValloxDataUpdateCoordinator def __init__( - self, name: str, client: Vallox, state_proxy: ValloxStateProxy + self, + name: str, + client: Vallox, + coordinator: ValloxDataUpdateCoordinator, ) -> None: """Initialize the fan.""" + super().__init__(coordinator) + self._client = client - self._state_proxy = state_proxy - self._is_on = False - self._preset_mode: str | None = None - self._fan_speed_home: int | None = None - self._fan_speed_away: int | None = None - self._fan_speed_boost: int | None = None self._attr_name = name - self._attr_available = False @property def supported_features(self) -> int: @@ -102,73 +113,24 @@ class ValloxFan(FanEntity): @property def is_on(self) -> bool: """Return if device is on.""" - return self._is_on + return self.coordinator.data.get_metric(METRIC_KEY_MODE) == MODE_ON @property def preset_mode(self) -> str | None: """Return the current preset mode.""" - return self._preset_mode + vallox_profile = self.coordinator.data.profile + return VALLOX_PROFILE_TO_STR_SETTABLE.get(vallox_profile) @property def extra_state_attributes(self) -> Mapping[str, int | None]: """Return device specific state attributes.""" + data = self.coordinator.data + return { - ATTR_PROFILE_FAN_SPEED_HOME["description"]: self._fan_speed_home, - ATTR_PROFILE_FAN_SPEED_AWAY["description"]: self._fan_speed_away, - ATTR_PROFILE_FAN_SPEED_BOOST["description"]: self._fan_speed_boost, + attr.description: _convert_fan_speed_value(data.get_metric(attr.metric_key)) + for attr in EXTRA_STATE_ATTRIBUTES } - async def async_added_to_hass(self) -> None: - """Call to update.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_VALLOX_STATE_UPDATE, self._update_callback - ) - ) - - @callback - def _update_callback(self) -> None: - """Call update method.""" - self.async_schedule_update_ha_state(True) - - async def async_update(self) -> None: - """Fetch state from the device.""" - try: - # Fetch if the whole device is in regular operation state. - self._is_on = self._state_proxy.fetch_metric(METRIC_KEY_MODE) == MODE_ON - - vallox_profile = self._state_proxy.get_profile() - - # Fetch the profile fan speeds. - fan_speed_home = self._state_proxy.fetch_metric( - ATTR_PROFILE_FAN_SPEED_HOME["metric_key"] - ) - fan_speed_away = self._state_proxy.fetch_metric( - ATTR_PROFILE_FAN_SPEED_AWAY["metric_key"] - ) - fan_speed_boost = self._state_proxy.fetch_metric( - ATTR_PROFILE_FAN_SPEED_BOOST["metric_key"] - ) - - except (OSError, KeyError, TypeError) as err: - self._attr_available = False - _LOGGER.error("Error updating fan: %s", err) - return - - self._preset_mode = VALLOX_PROFILE_TO_STR_SETTABLE.get(vallox_profile) - - self._fan_speed_home = ( - int(fan_speed_home) if isinstance(fan_speed_home, (int, float)) else None - ) - self._fan_speed_away = ( - int(fan_speed_away) if isinstance(fan_speed_away, (int, float)) else None - ) - self._fan_speed_boost = ( - int(fan_speed_boost) if isinstance(fan_speed_boost, (int, float)) else None - ) - - self._attr_available = True - async def _async_set_preset_mode_internal(self, preset_mode: str) -> bool: """ Set new preset mode. @@ -201,7 +163,7 @@ class ValloxFan(FanEntity): if update_needed: # This state change affects other entities like sensors. Force an immediate update that # can be observed by all parties involved. - await self._state_proxy.async_update() + await self.coordinator.async_request_refresh() async def async_turn_on( self, @@ -211,7 +173,7 @@ class ValloxFan(FanEntity): **kwargs: Any, ) -> None: """Turn the device on.""" - _LOGGER.debug("Turn on: %s", speed) + _LOGGER.debug("Turn on") update_needed = False @@ -231,7 +193,7 @@ class ValloxFan(FanEntity): if update_needed: # This state change affects other entities like sensors. Force an immediate update that # can be observed by all parties involved. - await self._state_proxy.async_update() + await self.coordinator.async_request_refresh() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" @@ -246,4 +208,4 @@ class ValloxFan(FanEntity): return # Same as for turn_on method. - await self._state_proxy.async_update() + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/vallox/sensor.py b/homeassistant/components/vallox/sensor.py index 7bf9dca700f..2fdfd2cd472 100644 --- a/homeassistant/components/vallox/sensor.py +++ b/homeassistant/components/vallox/sensor.py @@ -19,143 +19,91 @@ from homeassistant.const import ( PERCENTAGE, TEMP_CELSIUS, ) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import ValloxStateProxy -from .const import ( - DOMAIN, - METRIC_KEY_MODE, - MODE_ON, - SIGNAL_VALLOX_STATE_UPDATE, - VALLOX_PROFILE_TO_STR_REPORTABLE, -) +from . import ValloxDataUpdateCoordinator +from .const import DOMAIN, METRIC_KEY_MODE, MODE_ON, VALLOX_PROFILE_TO_STR_REPORTABLE _LOGGER = logging.getLogger(__name__) -class ValloxSensor(SensorEntity): +class ValloxSensor(CoordinatorEntity, SensorEntity): """Representation of a Vallox sensor.""" - _attr_should_poll = False entity_description: ValloxSensorEntityDescription + coordinator: ValloxDataUpdateCoordinator def __init__( self, name: str, - state_proxy: ValloxStateProxy, + coordinator: ValloxDataUpdateCoordinator, description: ValloxSensorEntityDescription, ) -> None: """Initialize the Vallox sensor.""" - self._state_proxy = state_proxy + super().__init__(coordinator) self.entity_description = description self._attr_name = f"{name} {description.name}" - self._attr_available = False - async def async_added_to_hass(self) -> None: - """Call to update.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_VALLOX_STATE_UPDATE, self._update_callback - ) - ) - - @callback - def _update_callback(self) -> None: - """Call update method.""" - self.async_schedule_update_ha_state(True) - - async def async_update(self) -> None: - """Fetch state from the ventilation unit.""" + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" if (metric_key := self.entity_description.metric_key) is None: - self._attr_available = False - _LOGGER.error("Error updating sensor. Empty metric key") - return + _LOGGER.debug("Error updating sensor. Empty metric key") + return None - try: - self._attr_native_value = self._state_proxy.fetch_metric(metric_key) - - except (OSError, KeyError, TypeError) as err: - self._attr_available = False - _LOGGER.error("Error updating sensor: %s", err) - return - - self._attr_available = True + return self.coordinator.data.get_metric(metric_key) class ValloxProfileSensor(ValloxSensor): """Child class for profile reporting.""" - async def async_update(self) -> None: - """Fetch state from the ventilation unit.""" - try: - vallox_profile = self._state_proxy.get_profile() - - except OSError as err: - self._attr_available = False - _LOGGER.error("Error updating sensor: %s", err) - return - - self._attr_native_value = VALLOX_PROFILE_TO_STR_REPORTABLE.get(vallox_profile) - self._attr_available = True + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + vallox_profile = self.coordinator.data.profile + return VALLOX_PROFILE_TO_STR_REPORTABLE.get(vallox_profile) -# There seems to be a quirk with respect to the fan speed reporting. The device keeps on reporting -# the last valid fan speed from when the device was in regular operation mode, even if it left that -# state and has been shut off in the meantime. +# There is a quirk with respect to the fan speed reporting. The device keeps on reporting the last +# valid fan speed from when the device was in regular operation mode, even if it left that state and +# has been shut off in the meantime. # # Therefore, first query the overall state of the device, and report zero percent fan speed in case # it is not in regular operation mode. class ValloxFanSpeedSensor(ValloxSensor): """Child class for fan speed reporting.""" - async def async_update(self) -> None: - """Fetch state from the ventilation unit.""" - try: - fan_on = self._state_proxy.fetch_metric(METRIC_KEY_MODE) == MODE_ON - - except (OSError, KeyError, TypeError) as err: - self._attr_available = False - _LOGGER.error("Error updating sensor: %s", err) - return - - if fan_on: - await super().async_update() - else: - # Report zero percent otherwise. - self._attr_native_value = 0 - self._attr_available = True + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + fan_is_on = self.coordinator.data.get_metric(METRIC_KEY_MODE) == MODE_ON + return super().native_value if fan_is_on else 0 class ValloxFilterRemainingSensor(ValloxSensor): """Child class for filter remaining time reporting.""" - async def async_update(self) -> None: - """Fetch state from the ventilation unit.""" - await super().async_update() + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + super_native_value = super().native_value - # Check if the update in the super call was a success. - if not self._attr_available: - return - - if not isinstance(self._attr_native_value, (int, float)): - self._attr_available = False - _LOGGER.error( - "Value has unexpected type: %s", type(self._attr_native_value) - ) - return + if not isinstance(super_native_value, (int, float)): + _LOGGER.debug("Value has unexpected type: %s", type(super_native_value)) + return None # Since only a delta of days is received from the device, fix the time so the timestamp does # not change with every update. - days_remaining = float(self._attr_native_value) + days_remaining = float(super_native_value) days_remaining_delta = timedelta(days=days_remaining) now = datetime.utcnow().replace(hour=13, minute=0, second=0, microsecond=0) - self._attr_native_value = (now + days_remaining_delta).isoformat() + return (now + days_remaining_delta).isoformat() @dataclass @@ -259,12 +207,11 @@ async def async_setup_platform( return name = hass.data[DOMAIN]["name"] - state_proxy = hass.data[DOMAIN]["state_proxy"] + coordinator = hass.data[DOMAIN]["coordinator"] async_add_entities( [ - description.sensor_type(name, state_proxy, description) + description.sensor_type(name, coordinator, description) for description in SENSORS - ], - update_before_add=False, + ] )