From 66fb89e92df2fb7241618957cdc0779f4ec4bae6 Mon Sep 17 00:00:00 2001 From: dandelionclock Date: Sat, 8 Jun 2024 20:30:44 -0400 Subject: [PATCH] feat: add cooling seats (#994) closes #977 --- custom_components/tesla_custom/manifest.json | 2 +- custom_components/tesla_custom/select.py | 118 +++++++++++++--- poetry.lock | 8 +- pyproject.toml | 2 +- scripts/setup | 2 +- tests/mock_data/car.py | 7 +- tests/test_select.py | 139 +++++++++++++++++++ 7 files changed, 249 insertions(+), 29 deletions(-) diff --git a/custom_components/tesla_custom/manifest.json b/custom_components/tesla_custom/manifest.json index 2c275f8d..f29df850 100644 --- a/custom_components/tesla_custom/manifest.json +++ b/custom_components/tesla_custom/manifest.json @@ -24,6 +24,6 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/alandtse/tesla/issues", "loggers": ["teslajsonpy"], - "requirements": ["teslajsonpy==3.11.0"], + "requirements": ["teslajsonpy==3.12.0"], "version": "3.22.2" } diff --git a/custom_components/tesla_custom/select.py b/custom_components/tesla_custom/select.py index 0fa6fd8d..ee652720 100644 --- a/custom_components/tesla_custom/select.py +++ b/custom_components/tesla_custom/select.py @@ -1,6 +1,7 @@ """Support for Tesla selects.""" import logging +import re from homeassistant.components.select import SelectEntity from homeassistant.core import HomeAssistant @@ -45,6 +46,17 @@ "Auto", ] +FRONT_COOL_HEAT_OPTIONS = [ + "Off", + "Heat Low", + "Heat Medium", + "Heat High", + "Auto", + "Cool Low", + "Cool Medium", + "Cool High", +] + STEERING_HEATER_OPTIONS = [ "Off", "Low", @@ -70,6 +82,7 @@ "third row right": 7, } +# Also used by cooled seats AUTO_SEAT_ID_MAP = { "left": 1, "right": 2, @@ -113,7 +126,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry, async_add_entitie class TeslaCarHeatedSeat(TeslaCarEntity, SelectEntity): - """Representation of a Tesla car heated seat select.""" + """Representation of a Tesla car heated/cooling seat select.""" _attr_icon = "mdi:car-seat-heater" @@ -123,7 +136,7 @@ def __init__( coordinator: TeslaDataUpdateCoordinator, seat_name: str, ): - """Initialize heated seat entity.""" + """Initialize heated/cooling seat entity.""" self._seat_name = seat_name self.type = f"heated seat {seat_name}" if SEAT_ID_MAP[self._seat_name] < 2: @@ -134,51 +147,114 @@ def __init__( async def async_select_option(self, option: str, **kwargs): """Change the selected option.""" + # If selected auto if self._is_auto_available and option == FRONT_HEATER_OPTIONS[4]: - _LOGGER.debug("Setting %s to %s", SEAT_ID_MAP[self._seat_name], option) + _LOGGER.debug("Setting %s to %s", self.name, option) await self._car.remote_auto_seat_climate_request( AUTO_SEAT_ID_MAP[self._seat_name], True ) + # If any options other than auto else: + # First turn off auto if currently on if self.current_option == FRONT_HEATER_OPTIONS[4]: + _LOGGER.debug("Turning off Auto heat/cool on %s", self.name) await self._car.remote_auto_seat_climate_request( AUTO_SEAT_ID_MAP[self._seat_name], False ) - - level: int = HEATER_OPTIONS.index(option) - - if not self._car.is_climate_on and level > 0: - await self._car.set_hvac_mode("on") - - _LOGGER.debug("Setting %s to %s", self.name, level) - await self._car.remote_seat_heater_request( - level, SEAT_ID_MAP[self._seat_name] - ) + # If front seat and car has seat cooling + if self._is_auto_available and self._car.has_seat_cooling: + level: int = FRONT_COOL_HEAT_OPTIONS.index(option) + if not self._car.is_climate_on and level > 0: + await self._car.set_hvac_mode("on") + # If turning off + if option == FRONT_COOL_HEAT_OPTIONS[0]: + _LOGGER.debug("Turning off Cooling/%s", self.name) + # If auto, turn off both heat and cool + if getattr(self._car, "is_auto_seat_climate_" + self._seat_name): + _LOGGER.debug( + "Currently on Auto, Turning off Both heat and cooling on Cooling/%s", + self.name, + ) + await self._car.remote_seat_heater_request( + level, SEAT_ID_MAP[self._seat_name] + ) + await self._car.remote_seat_cooler_request( + 1, AUTO_SEAT_ID_MAP[self._seat_name] + ) + # If heating, turn off heat + elif self._car.get_seat_heater_status(SEAT_ID_MAP[self._seat_name]): + await self._car.remote_seat_heater_request( + level, SEAT_ID_MAP[self._seat_name] + ) + # If cooling, turn off cooling, 1 is off + else: + await self._car.remote_seat_cooler_request( + 1, AUTO_SEAT_ID_MAP[self._seat_name] + ) + # If heat levels selected + elif re.search("Heat", option): + _LOGGER.debug("Setting Cooling/%s to heat %s", self.name, level) + await self._car.remote_seat_heater_request( + level, SEAT_ID_MAP[self._seat_name] + ) + # If cool levels selected + elif re.search("Cool", option): + # Cool Low == 2, Cool Medium == 3, Cool High ==4 + level = level - (FRONT_COOL_HEAT_OPTIONS.index("Cool Low") - 2) + _LOGGER.debug("Setting Cooling/%s to cool %s", self.name, level) + await self._car.remote_seat_cooler_request( + level, AUTO_SEAT_ID_MAP[self._seat_name] + ) + # If no seat cooling and not setting to auto + else: + level: int = HEATER_OPTIONS.index(option) + if not self._car.is_climate_on and level > 0: + await self._car.set_hvac_mode("on") + _LOGGER.debug("Setting %s to %s", self.name, level) + await self._car.remote_seat_heater_request( + level, SEAT_ID_MAP[self._seat_name] + ) await self.update_controller(force=True) @property def current_option(self): - """Return current heated seat setting.""" + """Return current heated/cooling seat setting.""" if self._is_auto_available and getattr( self._car, "is_auto_seat_climate_" + self._seat_name ): current_value = 4 - else: + return FRONT_HEATER_OPTIONS[current_value] + # If heated seats only + elif not self._car.has_seat_cooling: current_value = self._car.get_seat_heater_status( SEAT_ID_MAP[self._seat_name] ) - - if current_value is None: - return HEATER_OPTIONS[0] - - if self._is_auto_available: + if current_value is None: + return FRONT_HEATER_OPTIONS[0] return FRONT_HEATER_OPTIONS[current_value] - return HEATER_OPTIONS[current_value] + # If cooling/heating front seats + else: + current_value = self._car.get_seat_heater_status( + SEAT_ID_MAP[self._seat_name] + ) + if current_value is None: + current_value = self._car.get_seat_cooler_status( + AUTO_SEAT_ID_MAP[self._seat_name] + ) + # Cooling seat level 1 is off + if current_value == 1: + current_value = 0 + else: + # Low is 2 but is item 5 on select list + current_value = current_value + 3 + return FRONT_COOL_HEAT_OPTIONS[current_value] @property def options(self): """Return heated seat options.""" + if self._car.has_seat_cooling and self._is_auto_available: + return FRONT_COOL_HEAT_OPTIONS if self._is_auto_available: return FRONT_HEATER_OPTIONS return HEATER_OPTIONS diff --git a/poetry.lock b/poetry.lock index c7feaae8..aa47e787 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3743,13 +3743,13 @@ tests = ["pytest", "pytest-cov"] [[package]] name = "teslajsonpy" -version = "3.11.0" +version = "3.12.0" description = "A library to work with Tesla API." optional = false python-versions = "<4.0,>=3.7" files = [ - {file = "teslajsonpy-3.11.0-py3-none-any.whl", hash = "sha256:211ccd50e9831c68b56a727af7d108ad4387d0b8d391c1c208c9be1567201020"}, - {file = "teslajsonpy-3.11.0.tar.gz", hash = "sha256:c310304813f55f1f898992d3dfc6750e6216c086468586d53b5a203d95c828db"}, + {file = "teslajsonpy-3.12.0-py3-none-any.whl", hash = "sha256:6a8081c00b946598d0ed82ceb14255e41f74618466e4e91d176845a8ec61522a"}, + {file = "teslajsonpy-3.12.0.tar.gz", hash = "sha256:67c900d4a42905cd400ce8a64a15e6232978b17d46169cee7528e6fbd6485d7b"}, ] [package.dependencies] @@ -4401,4 +4401,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = ">=3.12,<3.13" -content-hash = "8620cec82e66e8f4f162e864a4d9e7fe70dc9c186aacb514ce0e124684696eff" +content-hash = "b0d220d605ae3c18403f522a1a8dcf877585876ed618867151e1f3ed701dcf07" diff --git a/pyproject.toml b/pyproject.toml index 0ef5407a..558b884a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ license = "Apache-2.0" [tool.poetry.dependencies] python = ">=3.12,<3.13" -teslajsonpy = "3.11.0" +teslajsonpy = "3.12.0" async-timeout = ">=4.0.0" diff --git a/scripts/setup b/scripts/setup index 078db180..1093f737 100755 --- a/scripts/setup +++ b/scripts/setup @@ -10,5 +10,5 @@ poetry config virtualenvs.create false poetry install --no-interaction # Keep this inline with any requirements that are in manifest.json -pip install git+https://github.com/zabuldon/teslajsonpy.git@dev#teslajsonpy==3.11.0 +pip install git+https://github.com/zabuldon/teslajsonpy.git@dev#teslajsonpy==3.12.0 pre-commit install --install-hooks diff --git a/tests/mock_data/car.py b/tests/mock_data/car.py index 96e65da6..e4cf6308 100644 --- a/tests/mock_data/car.py +++ b/tests/mock_data/car.py @@ -110,8 +110,13 @@ "passenger_temp_setting": 23.3, "remote_heater_control_enabled": False, "right_temp_direction": -309, + "seat_fan_front_left": 3, + "seat_fan_front_right": 1, "seat_heater_left": 0, "seat_heater_right": 0, + "seat_heater_rear_left": 0, + "seat_heater_rear_right": 0, + "seat_heater_rear_center": 0, "side_mirror_heaters": False, "supports_fan_only_cabin_overheat_protection": False, "timestamp": 1661641175268, @@ -166,7 +171,7 @@ "front_drive_unit": "NoneOrSmall", "has_air_suspension": False, "has_ludicrous_mode": False, - "has_seat_cooling": False, + "has_seat_cooling": True, "headlamp_type": "Hid", "interior_trim_type": "AllBlack", "motorized_charge_port": True, diff --git a/tests/test_select.py b/tests/test_select.py index a06f88c6..e8508983 100644 --- a/tests/test_select.py +++ b/tests/test_select.py @@ -56,6 +56,9 @@ async def test_car_heated_seat_select(hass: HomeAssistant) -> None: """Tests car heated seat select.""" await setup_platform(hass, SELECT_DOMAIN) + # Test cars with heated seats only + del car_mock_data.VEHICLE_DATA["vehicle_config"]["has_seat_cooling"] + car_mock_data.VEHICLE_DATA["vehicle_config"]["has_seat_cooling"] = False with patch( "teslajsonpy.car.TeslaCar.remote_seat_heater_request" ) as mock_remote_seat_heater_request: @@ -124,6 +127,142 @@ async def test_car_heated_seat_select(hass: HomeAssistant) -> None: mock_set_hvac_mode.assert_awaited_once_with("on") +async def test_car_cooling_seat_select(hass: HomeAssistant) -> None: + """Tests car cooling seat select.""" + await setup_platform(hass, SELECT_DOMAIN) + + # Test cars with cooling/heated seats + del car_mock_data.VEHICLE_DATA["vehicle_config"]["has_seat_cooling"] + car_mock_data.VEHICLE_DATA["vehicle_config"]["has_seat_cooling"] = True + + with patch( + "teslajsonpy.car.TeslaCar.remote_seat_heater_request" + ) as mock_remote_seat_heater_request: + # Test selecting "Off" + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.my_model_s_heated_seat_left", "option": "Off"}, + blocking=True, + ) + mock_remote_seat_heater_request.assert_awaited_once_with(0, 0) + # Test selecting "Low" + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.my_model_s_heated_seat_left", + "option": "Heat Low", + }, + blocking=True, + ) + mock_remote_seat_heater_request.assert_awaited_with(1, 0) + # Test selecting "Medium" + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.my_model_s_heated_seat_left", + "option": "Heat Medium", + }, + blocking=True, + ) + mock_remote_seat_heater_request.assert_awaited_with(2, 0) + # Test selecting "High" + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.my_model_s_heated_seat_left", + "option": "Heat High", + }, + blocking=True, + ) + mock_remote_seat_heater_request.assert_awaited_with(3, 0) + + with patch( + "teslajsonpy.car.TeslaCar.remote_seat_cooler_request" + ) as mock_remote_seat_cooler_request: + # Test selecting "Off" + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.my_model_s_heated_seat_left", "option": "Off"}, + blocking=True, + ) + mock_remote_seat_cooler_request.assert_awaited_once_with(1, 1) + # Test selecting "Low" + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.my_model_s_heated_seat_left", + "option": "Cool Low", + }, + blocking=True, + ) + mock_remote_seat_cooler_request.assert_awaited_with(2, 1) + # Test selecting "Medium" + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.my_model_s_heated_seat_left", + "option": "Cool Medium", + }, + blocking=True, + ) + mock_remote_seat_cooler_request.assert_awaited_with(3, 1) + # Test selecting "High" + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.my_model_s_heated_seat_left", + "option": "Cool High", + }, + blocking=True, + ) + mock_remote_seat_cooler_request.assert_awaited_with(4, 1) + + with patch( + "teslajsonpy.car.TeslaCar.remote_auto_seat_climate_request" + ) as mock_remote_auto_seat_climate_request: + # Test selecting "Auto" + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.my_model_s_heated_seat_left", "option": "Auto"}, + blocking=True, + ) + mock_remote_auto_seat_climate_request.assert_awaited_once_with(1, True) + # Test from "Auto" selection + car_mock_data.VEHICLE_DATA["climate_state"]["auto_seat_climate_left"] = True + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.my_model_s_heated_seat_left", + "option": "Cool Low", + }, + blocking=True, + ) + mock_remote_auto_seat_climate_request.assert_awaited_with(1, False) + + with patch("teslajsonpy.car.TeslaCar.set_hvac_mode") as mock_set_hvac_mode: + # Test climate_on check + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.my_model_s_heated_seat_left", + "option": "Cool Low", + }, + blocking=True, + ) + mock_set_hvac_mode.assert_awaited_once_with("on") + + async def test_cabin_overheat_protection(hass: HomeAssistant) -> None: """Tests car cabin overheat protection select.""" await setup_platform(hass, SELECT_DOMAIN)