diff --git a/homeassistant/components/nuheat/__init__.py b/homeassistant/components/nuheat/__init__.py index 80765d88866..b6d19121e29 100644 --- a/homeassistant/components/nuheat/__init__.py +++ b/homeassistant/components/nuheat/__init__.py @@ -1,5 +1,6 @@ """Support for NuHeat thermostats.""" import asyncio +from datetime import timedelta import logging import nuheat @@ -17,6 +18,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_SERIAL_NUMBER, DOMAIN, PLATFORMS @@ -68,6 +70,12 @@ async def async_setup(hass: HomeAssistant, config: dict): return True +def _get_thermostat(api, serial_number): + """Authenticate and create the thermostat object.""" + api.authenticate() + return api.get_thermostat(serial_number) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): """Set up NuHeat from a config entry.""" @@ -80,7 +88,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): api = nuheat.NuHeat(username, password) try: - await hass.async_add_executor_job(api.authenticate) + thermostat = await hass.async_add_executor_job( + _get_thermostat, api, serial_number + ) except requests.exceptions.Timeout as ex: raise ConfigEntryNotReady from ex except requests.exceptions.HTTPError as ex: @@ -95,7 +105,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): _LOGGER.error("Failed to login to nuheat: %s", ex) return False - hass.data[DOMAIN][entry.entry_id] = (api, serial_number) + async def _async_update_data(): + """Fetch data from API endpoint.""" + try: + await hass.async_add_executor_job(thermostat.get_data) + except Exception as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=f"nuheat {serial_number}", + update_method=_async_update_data, + update_interval=timedelta(minutes=5), + ) + + hass.data[DOMAIN][entry.entry_id] = (thermostat, coordinator) for component in PLATFORMS: hass.async_create_task( diff --git a/homeassistant/components/nuheat/climate.py b/homeassistant/components/nuheat/climate.py index 417beecee9a..e8f21fc89c2 100644 --- a/homeassistant/components/nuheat/climate.py +++ b/homeassistant/components/nuheat/climate.py @@ -1,5 +1,5 @@ """Support for NuHeat thermostats.""" -from datetime import datetime, timedelta +from datetime import datetime import logging import time @@ -22,8 +22,9 @@ from homeassistant.components.climate.const import ( SUPPORT_TARGET_TEMPERATURE, ) from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.core import callback from homeassistant.helpers import event as event_helper -from homeassistant.util import Throttle +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( DOMAIN, @@ -38,7 +39,6 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) # The device does not have an off function. # To turn it off set to min_temp and PRESET_PERMANENT_HOLD @@ -65,11 +65,10 @@ SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the NuHeat thermostat(s).""" - api, serial_number = hass.data[DOMAIN][config_entry.entry_id] + thermostat, coordinator = hass.data[DOMAIN][config_entry.entry_id] temperature_unit = hass.config.units.temperature_unit - thermostat = await hass.async_add_executor_job(api.get_thermostat, serial_number) - entity = NuHeatThermostat(thermostat, temperature_unit) + entity = NuHeatThermostat(coordinator, thermostat, temperature_unit) # No longer need a service as set_hvac_mode to auto does this # since climate 1.0 has been implemented @@ -77,16 +76,16 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async_add_entities([entity], True) -class NuHeatThermostat(ClimateEntity): +class NuHeatThermostat(CoordinatorEntity, ClimateEntity): """Representation of a NuHeat Thermostat.""" - def __init__(self, thermostat, temperature_unit): + def __init__(self, coordinator, thermostat, temperature_unit): """Initialize the thermostat.""" + super().__init__(coordinator) self._thermostat = thermostat self._temperature_unit = temperature_unit self._schedule_mode = None self._target_temperature = None - self._force_update = False @property def name(self): @@ -122,7 +121,7 @@ class NuHeatThermostat(ClimateEntity): @property def available(self): """Return the unique id.""" - return self._thermostat.online + return self.coordinator.last_update_success and self._thermostat.online def set_hvac_mode(self, hvac_mode): """Set the system mode.""" @@ -260,28 +259,30 @@ class NuHeatThermostat(ClimateEntity): # in the future to make sure the change actually # took effect event_helper.call_later( - self.hass, NUHEAT_API_STATE_SHIFT_DELAY, self._schedule_force_refresh + self.hass, NUHEAT_API_STATE_SHIFT_DELAY, self._forced_refresh ) - def _schedule_force_refresh(self, _): - self._force_update = True - self.schedule_update_ha_state(True) + async def _forced_refresh(self, *_) -> None: + """Force a refresh.""" + await self.coordinator.async_refresh() - def update(self): - """Get the latest state from the thermostat.""" - if self._force_update: - self._throttled_update(no_throttle=True) - self._force_update = False - else: - self._throttled_update() + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._update_internal_state() - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def _throttled_update(self, **kwargs): - """Get the latest state from the thermostat with a throttle.""" - self._thermostat.get_data() + @callback + def _update_internal_state(self): + """Update our internal state from the last api response.""" self._schedule_mode = self._thermostat.schedule_mode self._target_temperature = self._thermostat.target_temperature + @callback + def _handle_coordinator_update(self): + """Get the latest state from the thermostat.""" + self._update_internal_state() + self.async_write_ha_state() + @property def device_info(self): """Return the device_info of the device.""" diff --git a/homeassistant/components/nuheat/const.py b/homeassistant/components/nuheat/const.py index bd44dcb1711..9c4fc368226 100644 --- a/homeassistant/components/nuheat/const.py +++ b/homeassistant/components/nuheat/const.py @@ -8,7 +8,7 @@ CONF_SERIAL_NUMBER = "serial_number" MANUFACTURER = "NuHeat" -NUHEAT_API_STATE_SHIFT_DELAY = 1 +NUHEAT_API_STATE_SHIFT_DELAY = 2 TEMP_HOLD_TIME_SEC = 43200 diff --git a/tests/components/nuheat/mocks.py b/tests/components/nuheat/mocks.py index 9755335ccc1..ccfb031f043 100644 --- a/tests/components/nuheat/mocks.py +++ b/tests/components/nuheat/mocks.py @@ -103,6 +103,8 @@ def _get_mock_thermostat_schedule_temporary_hold(): target_celsius=43, target_fahrenheit=99, target_temperature=3729, + max_temperature=5000, + min_temperature=1, ) thermostat.get_data = Mock() diff --git a/tests/components/nuheat/test_climate.py b/tests/components/nuheat/test_climate.py index 51e80e6b3c1..453f0dab110 100644 --- a/tests/components/nuheat/test_climate.py +++ b/tests/components/nuheat/test_climate.py @@ -1,6 +1,10 @@ """The test for the NuHeat thermostat module.""" +from datetime import timedelta + from homeassistant.components.nuheat.const import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util from .mocks import ( _get_mock_nuheat, @@ -12,6 +16,7 @@ from .mocks import ( ) from tests.async_mock import patch +from tests.common import async_fire_time_changed async def test_climate_thermostat_run(hass): @@ -135,3 +140,23 @@ async def test_climate_thermostat_schedule_temporary_hold(hass): # Only test for a subset of attributes in case # HA changes the implementation and a new one appears assert all(item in state.attributes.items() for item in expected_attributes.items()) + + await hass.services.async_call( + "climate", + "set_temperature", + service_data={ATTR_ENTITY_ID: "climate.temp_bathroom", "temperature": 90}, + blocking=True, + ) + await hass.async_block_till_done() + + # opportunistic set + state = hass.states.get("climate.temp_bathroom") + assert state.attributes["preset_mode"] == "Temporary Hold" + assert state.attributes["temperature"] == 50.0 + + # and the api poll returns it to the mock + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=3)) + await hass.async_block_till_done() + state = hass.states.get("climate.temp_bathroom") + assert state.attributes["preset_mode"] == "Run Schedule" + assert state.attributes["temperature"] == 37.2