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 1c0efd9260..b3ae7e9421 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 691ca63965..1cd21634c8 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 cab3c42184..2b8bc4060d 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 94d074c406..9366a18b6f 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 86ace76c84..62f7a37723 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 d218e9ca93..7742f6b0dc 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 e1bf1a1406..3decfe3cd0 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 1c7e3e9661..ef7c225d8c 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 cf4f88351a..689c731a05 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 0000000000..dc073fbfd7 --- /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 35e017278a..fd4ae87ac6 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 899d2ce1f2..21e0e255ed 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),