From c0525396acaa3f8548632e8bef3c8c5210f25387 Mon Sep 17 00:00:00 2001 From: megabytemb Date: Mon, 25 Apr 2022 04:36:57 +1000 Subject: [PATCH] feat: add support for Heated Steering Wheel and Seats (#188) --- custom_components/tesla_custom/climate.py | 50 ++++++++++++++++ custom_components/tesla_custom/const.py | 2 + custom_components/tesla_custom/helpers.py | 73 +++++++++++++++++++++++ custom_components/tesla_custom/select.py | 55 +++++++++++++++++ custom_components/tesla_custom/switch.py | 32 ++++++++++ 5 files changed, 212 insertions(+) create mode 100644 custom_components/tesla_custom/helpers.py create mode 100644 custom_components/tesla_custom/select.py diff --git a/custom_components/tesla_custom/climate.py b/custom_components/tesla_custom/climate.py index f7f5a2ea..8a0d35b6 100644 --- a/custom_components/tesla_custom/climate.py +++ b/custom_components/tesla_custom/climate.py @@ -15,6 +15,7 @@ from . import DOMAIN as TESLA_DOMAIN from .tesla_device import TeslaDevice +from .helpers import get_device _LOGGER = logging.getLogger(__name__) @@ -97,6 +98,7 @@ async def async_set_hvac_mode(self, hvac_mode): await self.tesla_device.set_status(False) elif hvac_mode == HVAC_MODE_HEAT_COOL: await self.tesla_device.set_status(True) + await self.update_climate_related_devices() self.async_write_ha_state() @TeslaDevice.Decorators.check_for_reauth @@ -124,3 +126,51 @@ def preset_modes(self) -> list[str] | None: Requires SUPPORT_PRESET_MODE. """ return self.tesla_device.preset_modes + + async def update_climate_related_devices(self): + """Reset the Manual Update time on climate related devices. + + This way, their states are correctly reflected if they are dependant on the Climate state. + """ + + # This is really gross, and i kinda hate it. + # but its the only way i could figure out how to force an update on the underlying device + # thats in the teslajsonpy library. + # This could be fixed by doing a pr in the underlying library, + # but is ok for now. + + # This works by reseting the last update time in the underlying device. + # this does not cause an api call, but instead enabled the undering device + # to read from the shared climate data cache in the teslajsonpy library. + + # First, we need to force the controller to update, as the refresh functions asume it + # has been uddated. + # We have to manually update the controller becuase changing the HVAC state only updates its state in Home assistant, + # and not the underlying cache in cliamte_parms. This does mean we talk to Tesla, but we only do so Once. + + await self.tesla_device._controller.update( + self.tesla_device._id, wake_if_asleep=False, force=True + ) + + climate_devices = [ + ["switch", "heated steering switch"], + ["select", "heated seat left"], + ["select", "heated seat right"], + ["select", "heated seat rear_left"], + ["select", "heated seat rear_center"], + ["select", "heated seat rear_right"], + ] + + for c_device in climate_devices: + _LOGGER.debug("Refreshing Device: %s.%s", c_device[0], c_device[1]) + + device = await get_device( + self.hass, self.config_entry_id, c_device[0], c_device[1] + ) + if device is not None: + class_name = device.__class__.__name__ + attr_str = f"_{class_name}__manual_update_time" + setattr(device, attr_str, 0) + + # Does not cause an API call. + device.refresh() diff --git a/custom_components/tesla_custom/const.py b/custom_components/tesla_custom/const.py index 0c41d236..ef3234dc 100644 --- a/custom_components/tesla_custom/const.py +++ b/custom_components/tesla_custom/const.py @@ -18,6 +18,7 @@ "device_tracker", "switch", "button", + "select", ] ICONS = { @@ -37,6 +38,7 @@ "flash lights": "mdi:car-light-high", "trigger homelink": "mdi:garage", "solar panel": "mdi:solar-panel", + "heated steering wheel": "mdi:steering", } AUTH_CALLBACK_PATH = "/auth/tesla/callback" AUTH_CALLBACK_NAME = "auth:tesla:callback" diff --git a/custom_components/tesla_custom/helpers.py b/custom_components/tesla_custom/helpers.py new file mode 100644 index 00000000..85e63904 --- /dev/null +++ b/custom_components/tesla_custom/helpers.py @@ -0,0 +1,73 @@ +"""Helpers module. + +A collection of functions which may be used accross entities +""" +from .const import DOMAIN as TESLA_DOMAIN + +import asyncio +import async_timeout +import logging + +from homeassistant.core import HomeAssistant + +_LOGGER = logging.getLogger(__name__) + + +async def get_device( + hass: HomeAssistant, + config_entry_id: str, + device_category: str, + device_type: str, +): + """Get a tesla Device for a Config Entry ID.""" + + entry_data = hass.data[TESLA_DOMAIN][config_entry_id] + devices = entry_data["devices"].get(device_category, []) + + for device in devices: + if device.type == device_type: + return device + + return None + + +async def wait_for_climate( + hass: HomeAssistant, config_entry_id: str, timeout: int = 30 +): + """Wait for HVac. + + Optional Timeout. defaults to 30 seconds + """ + climate_device = await get_device( + hass, config_entry_id, "climate", "HVAC (climate) system" + ) + + if climate_device is None: + return None + + async with async_timeout.timeout(timeout): + while True: + hvac_mode = climate_device.is_hvac_enabled() + + if hvac_mode is True: + _LOGGER.debug("HVAC Enabled") + return True + else: + _LOGGER.info("Enabing Climate to activate Heated Steering Wheel") + + # The below is a blocking funtion (it waits for a reponse from the API). + # So it could eat into our timeout, and this is fine. + # We'll try to turn the set the status, and check again + try: + await climate_device.set_status(True) + continue + except: + # If we get an error, we'll just loop around and try again + pass + + # Wait two second between API calls, in case we get an error like car unavail, + # or any other random thing tesla throws at us + await asyncio.sleep(2) + + # we'll return false if the timeout is reached. + return False diff --git a/custom_components/tesla_custom/select.py b/custom_components/tesla_custom/select.py new file mode 100644 index 00000000..59682de1 --- /dev/null +++ b/custom_components/tesla_custom/select.py @@ -0,0 +1,55 @@ +"""Support for Tesla selects.""" +import logging + +from homeassistant.components.select import SelectEntity + +from . import DOMAIN as TESLA_DOMAIN +from .tesla_device import TeslaDevice +from .helpers import wait_for_climate + +_LOGGER = logging.getLogger(__name__) + +OPTIONS = [ + "Off", + "Low", + "Medium", + "High", +] + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Tesla selects by config_entry.""" + coordinator = hass.data[TESLA_DOMAIN][config_entry.entry_id]["coordinator"] + entities = [] + for device in hass.data[TESLA_DOMAIN][config_entry.entry_id]["devices"]["select"]: + if device.type.startswith("heated seat "): + entities.append(HeatedSeatSelect(device, coordinator)) + async_add_entities(entities, True) + + +class HeatedSeatSelect(TeslaDevice, SelectEntity): + """Representation of a Tesla Heated Seat Select.""" + + @TeslaDevice.Decorators.check_for_reauth + async def async_select_option(self, option: str, **kwargs): + """Change the selected option.""" + level: int = OPTIONS.index(option) + + await wait_for_climate(self.hass, self.config_entry_id) + _LOGGER.debug("Setting %s to %s", self.name, level) + await self.tesla_device.set_seat_heat_level(level) + self.async_write_ha_state() + + @property + def current_option(self): + """Return the selected entity option to represent the entity state.""" + current_value = self.tesla_device.get_seat_heat_level() + + if current_value is None: + return OPTIONS[0] + return OPTIONS[current_value] + + @property + def options(self): + """Return a set of selectable options.""" + return OPTIONS diff --git a/custom_components/tesla_custom/switch.py b/custom_components/tesla_custom/switch.py index e0ea9ac2..24726825 100644 --- a/custom_components/tesla_custom/switch.py +++ b/custom_components/tesla_custom/switch.py @@ -6,6 +6,7 @@ from . import DOMAIN as TESLA_DOMAIN from .tesla_device import TeslaDevice +from .helpers import wait_for_climate _LOGGER = logging.getLogger(__name__) @@ -22,9 +23,40 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entities.append(RangeSwitch(device, coordinator)) elif device.type == "sentry mode switch": entities.append(SentryModeSwitch(device, coordinator)) + elif device.type == "heated steering switch": + entities.append(HeatedSteeringWheelSwitch(device, coordinator)) async_add_entities(entities, True) +class HeatedSteeringWheelSwitch(TeslaDevice, SwitchEntity): + """Representation of a Tesla Heated Steering Wheel switch.""" + + @TeslaDevice.Decorators.check_for_reauth + async def async_turn_on(self, **kwargs): + """Send the on command.""" + _LOGGER.debug("Turn on Heating Steering Wheel: %s", self.name) + await wait_for_climate(self.hass, self.config_entry_id) + await self.tesla_device.set_steering_wheel_heat(True) + self.async_write_ha_state() + + @TeslaDevice.Decorators.check_for_reauth + async def async_turn_off(self, **kwargs): + """Send the off command.""" + _LOGGER.debug("Turn off Heating Steering Wheel: %s", self.name) + await self.tesla_device.set_steering_wheel_heat(False) + self.async_write_ha_state() + + @property + def is_on(self): + """Get whether the switch is in on state.""" + return self.tesla_device.get_steering_wheel_heat() + + @property + def icon(self): + """Return the icon of the sensor.""" + return ICONS.get("heated steering wheel") + + class ChargerSwitch(TeslaDevice, SwitchEntity): """Representation of a Tesla charger switch."""