From 9672db0354c6b612fa84c730aca2d69390f01105 Mon Sep 17 00:00:00 2001 From: chiefdragon <11260692+chiefdragon@users.noreply.github.com> Date: Tue, 23 May 2023 18:08:00 +0100 Subject: [PATCH] Add new preset to Tado to enable geofencing mode (#92877) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add new preset to Tado to enable geofencing mode Add new 'auto' preset mode to enable Tado to be set to auto geofencing mode. The existing ‘home’ and ‘away’ presets switched Tado into manual geofencing mode and there was no way to restore it to auto mode. Note 1: Since preset modes (home, away and auto) apply to the Tado home holistically, irrespective of the Tado climate entity used to select the preset, three new sensors have been added to display the state of the Tado home Note 2: Auto mode is only supported if the Auto Assist skill is enabled in the owner's Tado home. Various checks have been added to ensure the Tado supports auto geofencing and if it is not supported, the preset is not listed in the preset modes available * Update codeowners in manifest.json * Update main codeowners file for Tado component --- CODEOWNERS | 4 +- homeassistant/components/tado/__init__.py | 34 +++++++-- homeassistant/components/tado/climate.py | 41 ++++++++-- homeassistant/components/tado/const.py | 8 +- homeassistant/components/tado/manifest.json | 4 +- homeassistant/components/tado/sensor.py | 74 +++++++++++++++++-- homeassistant/components/tado/strings.json | 13 ++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/tado/fixtures/home_state.json | 4 + tests/components/tado/test_climate.py | 12 +-- tests/components/tado/util.py | 5 ++ 12 files changed, 174 insertions(+), 29 deletions(-) create mode 100644 tests/components/tado/fixtures/home_state.json diff --git a/CODEOWNERS b/CODEOWNERS index 1c0efd92609..b3ae7e94215 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1209,8 +1209,8 @@ build.json @home-assistant/supervisor /homeassistant/components/synology_srm/ @aerialls /homeassistant/components/system_bridge/ @timmo001 /tests/components/system_bridge/ @timmo001 -/homeassistant/components/tado/ @michaelarnauts -/tests/components/tado/ @michaelarnauts +/homeassistant/components/tado/ @michaelarnauts @chiefdragon +/tests/components/tado/ @michaelarnauts @chiefdragon /homeassistant/components/tag/ @balloob @dmulcahey /tests/components/tag/ @balloob @dmulcahey /homeassistant/components/tailscale/ @frenck diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 691ca639656..1cd21634c8e 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -25,6 +25,7 @@ from .const import ( DATA, DOMAIN, INSIDE_TEMPERATURE_MEASUREMENT, + PRESET_AUTO, SIGNAL_TADO_UPDATE_RECEIVED, TEMP_OFFSET, UPDATE_LISTENER, @@ -151,6 +152,7 @@ class TadoConnector: self.data = { "device": {}, "weather": {}, + "geofence": {}, "zone": {}, } @@ -175,11 +177,7 @@ class TadoConnector: """Update the registered zones.""" self.update_devices() self.update_zones() - self.data["weather"] = self.tado.getWeather() - dispatcher_send( - self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format(self.home_id, "weather", "data"), - ) + self.update_home() def update_devices(self): """Update the device data from Tado.""" @@ -250,10 +248,29 @@ class TadoConnector: SIGNAL_TADO_UPDATE_RECEIVED.format(self.home_id, "zone", zone_id), ) + def update_home(self): + """Update the home data from Tado.""" + try: + self.data["weather"] = self.tado.getWeather() + self.data["geofence"] = self.tado.getHomeState() + dispatcher_send( + self.hass, + SIGNAL_TADO_UPDATE_RECEIVED.format(self.home_id, "home", "data"), + ) + except RuntimeError: + _LOGGER.error( + "Unable to connect to Tado while updating weather and geofence data" + ) + return + def get_capabilities(self, zone_id): """Return the capabilities of the devices.""" return self.tado.getCapabilities(zone_id) + def get_auto_geofencing_supported(self): + """Return whether the Tado Home supports auto geofencing.""" + return self.tado.getAutoGeofencingSupported() + def reset_zone_overlay(self, zone_id): """Reset the zone back to the default operation.""" self.tado.resetZoneOverlay(zone_id) @@ -263,12 +280,17 @@ class TadoConnector: self, presence=PRESET_HOME, ): - """Set the presence to home or away.""" + """Set the presence to home, away or auto.""" if presence == PRESET_AWAY: self.tado.setAway() elif presence == PRESET_HOME: self.tado.setHome() + elif presence == PRESET_AUTO: + self.tado.setAuto() + + # Update everything when changing modes self.update_zones() + self.update_home() def set_zone_overlay( self, diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index cab3c42184e..2b8bc4060d6 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -44,8 +44,10 @@ from .const import ( HA_TO_TADO_HVAC_MODE_MAP, HA_TO_TADO_SWING_MODE_MAP, ORDERED_KNOWN_TADO_MODES, + PRESET_AUTO, SIGNAL_TADO_UPDATE_RECEIVED, - SUPPORT_PRESET, + SUPPORT_PRESET_AUTO, + SUPPORT_PRESET_MANUAL, TADO_HVAC_ACTION_TO_HA_HVAC_ACTION, TADO_MODES_WITH_NO_TEMP_SETTING, TADO_SWING_OFF, @@ -245,6 +247,8 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): self._attr_name = zone_name self._attr_temperature_unit = UnitOfTemperature.CELSIUS + self._attr_translation_key = DOMAIN + self._device_info = device_info self._device_id = self._device_info["shortSerialNo"] @@ -274,21 +278,31 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): self._current_tado_swing_mode = TADO_SWING_OFF self._tado_zone_data = None + self._tado_geofence_data = None self._tado_zone_temp_offset = {} + self._async_update_home_data() self._async_update_zone_data() async def async_added_to_hass(self) -> None: """Register for sensor updates.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + SIGNAL_TADO_UPDATE_RECEIVED.format(self._tado.home_id, "home", "data"), + self._async_update_home_callback, + ) + ) + self.async_on_remove( async_dispatcher_connect( self.hass, SIGNAL_TADO_UPDATE_RECEIVED.format( self._tado.home_id, "zone", self.zone_id ), - self._async_update_callback, + self._async_update_zone_callback, ) ) @@ -346,7 +360,11 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): @property def preset_mode(self): - """Return the current preset mode (home, away).""" + """Return the current preset mode (home, away or auto).""" + + if "presenceLocked" in self._tado_geofence_data: + if not self._tado_geofence_data["presenceLocked"]: + return PRESET_AUTO if self._tado_zone_data.is_away: return PRESET_AWAY return PRESET_HOME @@ -354,7 +372,9 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): @property def preset_modes(self): """Return a list of available preset modes.""" - return SUPPORT_PRESET + if self._tado.get_auto_geofencing_supported(): + return SUPPORT_PRESET_AUTO + return SUPPORT_PRESET_MANUAL def set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" @@ -501,11 +521,22 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): self._current_tado_swing_mode = self._tado_zone_data.current_swing_mode @callback - def _async_update_callback(self): + def _async_update_zone_callback(self): """Load tado data and update state.""" self._async_update_zone_data() self.async_write_ha_state() + @callback + def _async_update_home_data(self): + """Load tado geofencing data into zone.""" + self._tado_geofence_data = self._tado.data["geofence"] + + @callback + def _async_update_home_callback(self): + """Load tado data and update state.""" + self._async_update_home_data() + self.async_write_ha_state() + def _normalize_target_temp_for_hvac_mode(self): # Set a target temperature if we don't have any # This can happen when we switch from Off to On diff --git a/homeassistant/components/tado/const.py b/homeassistant/components/tado/const.py index 94d074c4066..9366a18b6fe 100644 --- a/homeassistant/components/tado/const.py +++ b/homeassistant/components/tado/const.py @@ -153,8 +153,14 @@ TADO_TO_HA_FAN_MODE_MAP = {value: key for key, value in HA_TO_TADO_FAN_MODE_MAP. DEFAULT_TADO_PRECISION = 0.1 -SUPPORT_PRESET = [PRESET_AWAY, PRESET_HOME] +# Constant for Auto Geolocation mode +PRESET_AUTO = "auto" +SUPPORT_PRESET_AUTO = [PRESET_AWAY, PRESET_HOME, PRESET_AUTO] +SUPPORT_PRESET_MANUAL = [PRESET_AWAY, PRESET_HOME] + +SENSOR_DATA_CATEGORY_WEATHER = "weather" +SENSOR_DATA_CATEGORY_GEOFENCE = "geofence" TADO_SWING_OFF = "OFF" TADO_SWING_ON = "ON" diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index 86ace76c849..62f7a377239 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -1,7 +1,7 @@ { "domain": "tado", "name": "Tado", - "codeowners": ["@michaelarnauts"], + "codeowners": ["@michaelarnauts", "@chiefdragon"], "config_flow": true, "dhcp": [ { @@ -14,5 +14,5 @@ }, "iot_class": "cloud_polling", "loggers": ["PyTado"], - "requirements": ["python-tado==0.12.0"] + "requirements": ["python-tado==0.15.0"] } diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py index d218e9ca933..7742f6b0dca 100644 --- a/homeassistant/components/tado/sensor.py +++ b/homeassistant/components/tado/sensor.py @@ -23,6 +23,8 @@ from .const import ( CONDITIONS_MAP, DATA, DOMAIN, + SENSOR_DATA_CATEGORY_GEOFENCE, + SENSOR_DATA_CATEGORY_WEATHER, SIGNAL_TADO_UPDATE_RECEIVED, TYPE_AIR_CONDITIONING, TYPE_HEATING, @@ -47,6 +49,7 @@ class TadoSensorEntityDescription( """Describes Tado sensor entity.""" attributes_fn: Callable[[Any], dict[Any, StateType]] | None = None + data_category: str | None = None HOME_SENSORS = [ @@ -60,6 +63,7 @@ HOME_SENSORS = [ native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, + data_category=SENSOR_DATA_CATEGORY_WEATHER, ), TadoSensorEntityDescription( key="solar percentage", @@ -70,12 +74,35 @@ HOME_SENSORS = [ }, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + data_category=SENSOR_DATA_CATEGORY_WEATHER, ), TadoSensorEntityDescription( key="weather condition", name="Weather condition", state_fn=lambda data: format_condition(data["weatherState"]["value"]), attributes_fn=lambda data: {"time": data["weatherState"]["timestamp"]}, + data_category=SENSOR_DATA_CATEGORY_WEATHER, + ), + TadoSensorEntityDescription( + key="tado mode", + name="Tado mode", + # pylint: disable=unnecessary-lambda + state_fn=lambda data: get_tado_mode(data), + data_category=SENSOR_DATA_CATEGORY_GEOFENCE, + ), + TadoSensorEntityDescription( + key="geofencing mode", + name="Geofencing mode", + # pylint: disable=unnecessary-lambda + state_fn=lambda data: get_geofencing_mode(data), + data_category=SENSOR_DATA_CATEGORY_GEOFENCE, + ), + TadoSensorEntityDescription( + key="automatic geofencing", + name="Automatic geofencing", + # pylint: disable=unnecessary-lambda + state_fn=lambda data: get_automatic_geofencing(data), + data_category=SENSOR_DATA_CATEGORY_GEOFENCE, ), ] @@ -145,6 +172,39 @@ def format_condition(condition: str) -> str: return condition +def get_tado_mode(data) -> str | None: + """Return Tado Mode based on Presence attribute.""" + if "presence" in data: + return data["presence"] + return None + + +def get_automatic_geofencing(data) -> bool: + """Return whether Automatic Geofencing is enabled based on Presence Locked attribute.""" + if "presenceLocked" in data: + if data["presenceLocked"]: + return False + return True + return False + + +def get_geofencing_mode(data) -> str: + """Return Geofencing Mode based on Presence and Presence Locked attributes.""" + tado_mode = "" + tado_mode = data.get("presence", "unknown") + + geofencing_switch_mode = "" + if "presenceLocked" in data: + if data["presenceLocked"]: + geofencing_switch_mode = "manual" + else: + geofencing_switch_mode = "auto" + else: + geofencing_switch_mode = "manual" + + return f"{tado_mode.capitalize()} ({geofencing_switch_mode.capitalize()})" + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -200,9 +260,7 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity): self.async_on_remove( async_dispatcher_connect( self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format( - self._tado.home_id, "weather", "data" - ), + SIGNAL_TADO_UPDATE_RECEIVED.format(self._tado.home_id, "home", "data"), self._async_update_callback, ) ) @@ -219,13 +277,19 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity): """Handle update callbacks.""" try: tado_weather_data = self._tado.data["weather"] + tado_geofence_data = self._tado.data["geofence"] except KeyError: return - self._attr_native_value = self.entity_description.state_fn(tado_weather_data) + if self.entity_description.data_category is not None: + if self.entity_description.data_category == SENSOR_DATA_CATEGORY_WEATHER: + tado_sensor_data = tado_weather_data + else: + tado_sensor_data = tado_geofence_data + self._attr_native_value = self.entity_description.state_fn(tado_sensor_data) if self.entity_description.attributes_fn is not None: self._attr_extra_state_attributes = self.entity_description.attributes_fn( - tado_weather_data + tado_sensor_data ) diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index e1bf1a1406d..3decfe3cd0c 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -29,5 +29,18 @@ "title": "Adjust Tado options." } } + }, + "entity": { + "climate": { + "tado": { + "state_attributes": { + "preset_mode": { + "state": { + "auto": "Auto" + } + } + } + } + } } } diff --git a/requirements_all.txt b/requirements_all.txt index 1c7e3e96618..ef7c225d8c4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2123,7 +2123,7 @@ python-smarttub==0.0.33 python-songpal==0.15.2 # homeassistant.components.tado -python-tado==0.12.0 +python-tado==0.15.0 # homeassistant.components.telegram_bot python-telegram-bot==13.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cf4f88351a3..689c731a057 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1543,7 +1543,7 @@ python-smarttub==0.0.33 python-songpal==0.15.2 # homeassistant.components.tado -python-tado==0.12.0 +python-tado==0.15.0 # homeassistant.components.telegram_bot python-telegram-bot==13.1 diff --git a/tests/components/tado/fixtures/home_state.json b/tests/components/tado/fixtures/home_state.json new file mode 100644 index 00000000000..dc073fbfd78 --- /dev/null +++ b/tests/components/tado/fixtures/home_state.json @@ -0,0 +1,4 @@ +{ + "presence": "HOME", + "presenceLocked": false +} diff --git a/tests/components/tado/test_climate.py b/tests/components/tado/test_climate.py index 35e017278aa..fd4ae87ac64 100644 --- a/tests/components/tado/test_climate.py +++ b/tests/components/tado/test_climate.py @@ -22,8 +22,8 @@ async def test_air_con(hass: HomeAssistant) -> None: "hvac_modes": ["off", "auto", "heat", "cool", "heat_cool", "dry", "fan_only"], "max_temp": 31.0, "min_temp": 16.0, - "preset_mode": "home", - "preset_modes": ["away", "home"], + "preset_mode": "auto", + "preset_modes": ["away", "home", "auto"], "supported_features": 25, "target_temp_step": 1, "temperature": 17.8, @@ -49,8 +49,8 @@ async def test_heater(hass: HomeAssistant) -> None: "hvac_modes": ["off", "auto", "heat"], "max_temp": 31.0, "min_temp": 16.0, - "preset_mode": "home", - "preset_modes": ["away", "home"], + "preset_mode": "auto", + "preset_modes": ["away", "home", "auto"], "supported_features": 17, "target_temp_step": 1, "temperature": 20.5, @@ -78,8 +78,8 @@ async def test_smartac_with_swing(hass: HomeAssistant) -> None: "hvac_modes": ["off", "auto", "heat", "cool", "heat_cool", "dry", "fan_only"], "max_temp": 30.0, "min_temp": 16.0, - "preset_mode": "home", - "preset_modes": ["away", "home"], + "preset_mode": "auto", + "preset_modes": ["away", "home", "auto"], "swing_modes": ["on", "off"], "supported_features": 57, "target_temp_step": 1.0, diff --git a/tests/components/tado/util.py b/tests/components/tado/util.py index 899d2ce1f21..21e0e255ed1 100644 --- a/tests/components/tado/util.py +++ b/tests/components/tado/util.py @@ -19,6 +19,7 @@ async def async_init_integration( devices_fixture = "tado/devices.json" me_fixture = "tado/me.json" weather_fixture = "tado/weather.json" + home_state_fixture = "tado/home_state.json" zones_fixture = "tado/zones.json" zone_states_fixture = "tado/zone_states.json" @@ -61,6 +62,10 @@ async def async_init_integration( "https://my.tado.com/api/v2/homes/1/weather", text=load_fixture(weather_fixture), ) + m.get( + "https://my.tado.com/api/v2/homes/1/state", + text=load_fixture(home_state_fixture), + ) m.get( "https://my.tado.com/api/v2/homes/1/devices", text=load_fixture(devices_fixture),