From 38ab121db53d2c72a0e4a3585ed8c657259a1999 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Mon, 10 Jun 2024 02:30:36 +1000 Subject: [PATCH] Add cabin overheat protection entity to Teslemetry (#118449) * test_cabin_overheat_protection * Fix snapshot * Translate error * Review Feedback --- .../components/teslemetry/climate.py | 152 +++++++++++++- .../components/teslemetry/strings.json | 6 + .../teslemetry/fixtures/vehicle_data.json | 2 +- .../teslemetry/fixtures/vehicle_data_alt.json | 2 +- .../teslemetry/snapshots/test_climate.ambr | 196 ++++++++++++++++++ .../snapshots/test_diagnostics.ambr | 2 +- tests/components/teslemetry/test_climate.py | 99 +++++++++ 7 files changed, 449 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/teslemetry/climate.py b/homeassistant/components/teslemetry/climate.py index f32aca26636..a70dc5a360a 100644 --- a/homeassistant/components/teslemetry/climate.py +++ b/homeassistant/components/teslemetry/climate.py @@ -2,9 +2,10 @@ from __future__ import annotations +from itertools import chain from typing import Any, cast -from tesla_fleet_api.const import Scope +from tesla_fleet_api.const import CabinOverheatProtectionTemp, Scope from homeassistant.components.climate import ( ATTR_HVAC_MODE, @@ -12,12 +13,18 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature +from homeassistant.const import ( + ATTR_TEMPERATURE, + PRECISION_HALVES, + PRECISION_WHOLE, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TeslemetryConfigEntry -from .const import TeslemetryClimateSide +from .const import DOMAIN, TeslemetryClimateSide from .entity import TeslemetryVehicleEntity from .models import TeslemetryVehicleData @@ -33,15 +40,25 @@ async def async_setup_entry( """Set up the Teslemetry Climate platform from a config entry.""" async_add_entities( - TeslemetryClimateEntity( - vehicle, TeslemetryClimateSide.DRIVER, entry.runtime_data.scopes + chain( + ( + TeslemetryClimateEntity( + vehicle, TeslemetryClimateSide.DRIVER, entry.runtime_data.scopes + ) + for vehicle in entry.runtime_data.vehicles + ), + ( + TeslemetryCabinOverheatProtectionEntity( + vehicle, entry.runtime_data.scopes + ) + for vehicle in entry.runtime_data.vehicles + ), ) - for vehicle in entry.runtime_data.vehicles ) class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity): - """Vehicle Location Climate Class.""" + """Telemetry vehicle climate entity.""" _attr_precision = PRECISION_HALVES @@ -153,3 +170,124 @@ class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity): else: self._attr_hvac_mode = HVACMode.HEAT_COOL self.async_write_ha_state() + + +COP_MODES = { + "Off": HVACMode.OFF, + "On": HVACMode.COOL, + "FanOnly": HVACMode.FAN_ONLY, +} + +COP_LEVELS = { + "Low": 30, + "Medium": 35, + "High": 40, +} + + +class TeslemetryCabinOverheatProtectionEntity(TeslemetryVehicleEntity, ClimateEntity): + """Telemetry vehicle cabin overheat protection entity.""" + + _attr_precision = PRECISION_WHOLE + _attr_target_temperature_step = 5 + _attr_min_temp = 30 + _attr_max_temp = 40 + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_modes = list(COP_MODES.values()) + _enable_turn_on_off_backwards_compatibility = False + _attr_entity_registry_enabled_default = False + + def __init__( + self, + data: TeslemetryVehicleData, + scopes: Scope, + ) -> None: + """Initialize the climate.""" + + super().__init__(data, "climate_state_cabin_overheat_protection") + + # Supported Features + self._attr_supported_features = ( + ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF + ) + if self.get("vehicle_config_cop_user_set_temp_supported"): + self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE + + # Scopes + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: + self._attr_supported_features = ClimateEntityFeature(0) + + def _async_update_attrs(self) -> None: + """Update the attributes of the entity.""" + + if (state := self.get("climate_state_cabin_overheat_protection")) is None: + self._attr_hvac_mode = None + else: + self._attr_hvac_mode = COP_MODES.get(state) + + if (level := self.get("climate_state_cop_activation_temperature")) is None: + self._attr_target_temperature = None + else: + self._attr_target_temperature = COP_LEVELS.get(level) + + self._attr_current_temperature = self.get("climate_state_inside_temp") + + async def async_turn_on(self) -> None: + """Set the climate state to on.""" + await self.async_set_hvac_mode(HVACMode.COOL) + + async def async_turn_off(self) -> None: + """Set the climate state to off.""" + await self.async_set_hvac_mode(HVACMode.OFF) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the climate temperature.""" + self.raise_for_scope() + + if not (temp := kwargs.get(ATTR_TEMPERATURE)): + return + + if temp == 30: + cop_mode = CabinOverheatProtectionTemp.LOW + elif temp == 35: + cop_mode = CabinOverheatProtectionTemp.MEDIUM + elif temp == 40: + cop_mode = CabinOverheatProtectionTemp.HIGH + else: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_cop_temp", + ) + + await self.wake_up_if_asleep() + await self.handle_command(self.api.set_cop_temp(cop_mode)) + self._attr_target_temperature = temp + + if mode := kwargs.get(ATTR_HVAC_MODE): + await self._async_set_cop(mode) + + self.async_write_ha_state() + + async def _async_set_cop(self, hvac_mode: HVACMode) -> None: + if hvac_mode == HVACMode.OFF: + await self.handle_command( + self.api.set_cabin_overheat_protection(on=False, fan_only=False) + ) + elif hvac_mode == HVACMode.COOL: + await self.handle_command( + self.api.set_cabin_overheat_protection(on=True, fan_only=False) + ) + elif hvac_mode == HVACMode.FAN_ONLY: + await self.handle_command( + self.api.set_cabin_overheat_protection(on=True, fan_only=True) + ) + + self._attr_hvac_mode = hvac_mode + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the climate mode and state.""" + self.raise_for_scope() + await self.wake_up_if_asleep() + await self._async_set_cop(hvac_mode) + self.async_write_ha_state() diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index b1b794404f4..d3740db9760 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -117,6 +117,9 @@ } }, "climate": { + "climate_state_cabin_overheat_protection": { + "name": "Cabin overheat protection" + }, "driver_temp": { "name": "[%key:component::climate::title%]", "state_attributes": { @@ -464,6 +467,9 @@ "exceptions": { "no_cable": { "message": "Charge cable will lock automatically when connected" + }, + "invalid_cop_temp": { + "message": "Cabin overheat protection does not support that temperature" } } } diff --git a/tests/components/teslemetry/fixtures/vehicle_data.json b/tests/components/teslemetry/fixtures/vehicle_data.json index 50022d7f4e9..6c787df4897 100644 --- a/tests/components/teslemetry/fixtures/vehicle_data.json +++ b/tests/components/teslemetry/fixtures/vehicle_data.json @@ -148,7 +148,7 @@ "car_special_type": "base", "car_type": "model3", "charge_port_type": "CCS", - "cop_user_set_temp_supported": false, + "cop_user_set_temp_supported": true, "dashcam_clip_save_supported": true, "default_charge_to_max": false, "driver_assist": "TeslaAP3", diff --git a/tests/components/teslemetry/fixtures/vehicle_data_alt.json b/tests/components/teslemetry/fixtures/vehicle_data_alt.json index 46f65e90760..76416982eba 100644 --- a/tests/components/teslemetry/fixtures/vehicle_data_alt.json +++ b/tests/components/teslemetry/fixtures/vehicle_data_alt.json @@ -81,7 +81,7 @@ "cabin_overheat_protection": "Off", "cabin_overheat_protection_actively_cooling": false, "climate_keeper_mode": "off", - "cop_activation_temperature": "High", + "cop_activation_temperature": "Low", "defrost_mode": 0, "driver_temp_setting": 22, "fan_status": 0, diff --git a/tests/components/teslemetry/snapshots/test_climate.ambr b/tests/components/teslemetry/snapshots/test_climate.ambr index b25baf239c9..b65796fe10e 100644 --- a/tests/components/teslemetry/snapshots/test_climate.ambr +++ b/tests/components/teslemetry/snapshots/test_climate.ambr @@ -1,4 +1,70 @@ # serializer version: 1 +# name: test_climate[climate.test_cabin_overheat_protection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 40, + 'min_temp': 30, + 'target_temp_step': 5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_cabin_overheat_protection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cabin overheat protection', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'climate_state_cabin_overheat_protection', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_cabin_overheat_protection', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate[climate.test_cabin_overheat_protection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 30, + 'friendly_name': 'Test Cabin overheat protection', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 40, + 'min_temp': 30, + 'supported_features': , + 'target_temp_step': 5, + 'temperature': 40, + }), + 'context': , + 'entity_id': 'climate.test_cabin_overheat_protection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- # name: test_climate[climate.test_climate-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -74,6 +140,71 @@ 'state': 'heat_cool', }) # --- +# name: test_climate_alt[climate.test_cabin_overheat_protection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 40, + 'min_temp': 30, + 'target_temp_step': 5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_cabin_overheat_protection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cabin overheat protection', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'climate_state_cabin_overheat_protection', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_cabin_overheat_protection', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_alt[climate.test_cabin_overheat_protection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 30, + 'friendly_name': 'Test Cabin overheat protection', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 40, + 'min_temp': 30, + 'supported_features': , + 'target_temp_step': 5, + }), + 'context': , + 'entity_id': 'climate.test_cabin_overheat_protection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_climate_alt[climate.test_climate-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -149,6 +280,71 @@ 'state': 'off', }) # --- +# name: test_climate_offline[climate.test_cabin_overheat_protection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 40, + 'min_temp': 30, + 'target_temp_step': 5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_cabin_overheat_protection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cabin overheat protection', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'climate_state_cabin_overheat_protection', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_cabin_overheat_protection', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_offline[climate.test_cabin_overheat_protection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': None, + 'friendly_name': 'Test Cabin overheat protection', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 40, + 'min_temp': 30, + 'supported_features': , + 'target_temp_step': 5, + }), + 'context': , + 'entity_id': 'climate.test_cabin_overheat_protection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_climate_offline[climate.test_climate-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/teslemetry/snapshots/test_diagnostics.ambr b/tests/components/teslemetry/snapshots/test_diagnostics.ambr index d7348d66d07..d13c4f48068 100644 --- a/tests/components/teslemetry/snapshots/test_diagnostics.ambr +++ b/tests/components/teslemetry/snapshots/test_diagnostics.ambr @@ -307,7 +307,7 @@ 'vehicle_config_car_special_type': 'base', 'vehicle_config_car_type': 'model3', 'vehicle_config_charge_port_type': 'CCS', - 'vehicle_config_cop_user_set_temp_supported': False, + 'vehicle_config_cop_user_set_temp_supported': True, 'vehicle_config_dashcam_clip_save_supported': True, 'vehicle_config_default_charge_to_max': False, 'vehicle_config_driver_assist': 'TeslaAP3', diff --git a/tests/components/teslemetry/test_climate.py b/tests/components/teslemetry/test_climate.py index 0e21533083c..1ea21554659 100644 --- a/tests/components/teslemetry/test_climate.py +++ b/tests/components/teslemetry/test_climate.py @@ -10,11 +10,14 @@ from tesla_fleet_api.exceptions import InvalidCommand, VehicleOffline from homeassistant.components.climate import ( ATTR_HVAC_MODE, ATTR_PRESET_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, ATTR_TEMPERATURE, DOMAIN as CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, SERVICE_SET_TEMPERATURE, + SERVICE_TURN_OFF, SERVICE_TURN_ON, HVACMode, ) @@ -37,6 +40,7 @@ from .const import ( from tests.common import async_fire_time_changed +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_climate( hass: HomeAssistant, snapshot: SnapshotAssertion, @@ -108,7 +112,100 @@ async def test_climate( state = hass.states.get(entity_id) assert state.state == HVACMode.OFF + entity_id = "climate.test_cabin_overheat_protection" + # Turn On and Set Low + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: [entity_id], + ATTR_TEMPERATURE: 30, + ATTR_HVAC_MODE: HVACMode.FAN_ONLY, + }, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 30 + assert state.state == HVACMode.FAN_ONLY + + # Set Temp Medium + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: [entity_id], + ATTR_TEMPERATURE: 35, + }, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 35 + + # Set Temp High + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: [entity_id], + ATTR_TEMPERATURE: 40, + }, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 40 + + # Turn Off + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == HVACMode.OFF + + # Turn On + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == HVACMode.COOL + + # Set Temp do nothing + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: [entity_id], + ATTR_TARGET_TEMP_HIGH: 30, + ATTR_TARGET_TEMP_LOW: 30, + }, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 40 + assert state.state == HVACMode.COOL + + # pytest raises ServiceValidationError + with pytest.raises( + ServiceValidationError, + match="Cabin overheat protection does not support that temperature", + ) as error: + # Invalid Temp + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: [entity_id], ATTR_TEMPERATURE: 25}, + blocking=True, + ) + assert error + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_climate_alt( hass: HomeAssistant, snapshot: SnapshotAssertion, @@ -122,6 +219,7 @@ async def test_climate_alt( assert_entities(hass, entry.entry_id, entity_registry, snapshot) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_climate_offline( hass: HomeAssistant, snapshot: SnapshotAssertion, @@ -204,6 +302,7 @@ async def test_ignored_error( mock_on.assert_called_once() +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_asleep_or_offline( hass: HomeAssistant, mock_vehicle_data,