diff --git a/custom_components/teslemetry/__init__.py b/custom_components/teslemetry/__init__.py index 15b222c..b938e30 100644 --- a/custom_components/teslemetry/__init__.py +++ b/custom_components/teslemetry/__init__.py @@ -37,9 +37,9 @@ PLATFORMS: Final = [ Platform.BINARY_SENSOR, - #Platform.BUTTON, + Platform.BUTTON, #Platform.COVER, - #Platform.CLIMATE, + Platform.CLIMATE, #Platform.DEVICE_TRACKER, #Platform.LOCK, #Platform.MEDIA_PLAYER, diff --git a/custom_components/teslemetry/climate.py b/custom_components/teslemetry/climate.py index 40e4d2a..2809706 100644 --- a/custom_components/teslemetry/climate.py +++ b/custom_components/teslemetry/climate.py @@ -1,6 +1,7 @@ """Climate platform for Teslemetry integration.""" from typing import Any, cast +from itertools import chain from tesla_fleet_api.const import Scope, CabinOverheatProtectionTemp from teslemetry_stream import Signal @@ -23,33 +24,39 @@ from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.restore_state import RestoreEntity -from .const import TeslemetryClimateSide, TeslemetryTimestamp +from .const import TeslemetryClimateSide from .entity import TeslemetryVehicleComplexStreamEntity, TeslemetryVehicleEntity from .models import TeslemetryVehicleData -DEFAULT_MIN_TEMP = 15 -DEFAULT_MAX_TEMP = 28 - - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Teslemetry Climate platform from a config entry.""" - entities = [] - for vehicle in entry.runtime_data.vehicles: - if True or vehicle.api.pre2021 or vehicle.firmware < "2024.44.25": - # Vehicle cannot use streaming - entities.append(TeslemetryPollingClimateEntity( - vehicle, TeslemetryClimateSide.DRIVER, entry.runtime_data.scopes - )) - entities.append(TeslemetryCabinOverheatProtectionEntity(vehicle, entry.runtime_data.scopes)) - else: - entities.append(TeslemetryStreamingClimateEntity( + async_add_entities( + chain(( + TeslemetryPollingClimateEntity( vehicle, TeslemetryClimateSide.DRIVER, entry.runtime_data.scopes - )) - entities.append(TeslemetryCabinOverheatProtectionEntity(vehicle, entry.runtime_data.scopes)) + ) + if True or vehicle.api.pre2021 or vehicle.firmware < "2024.44.25" + else TeslemetryStreamingClimateEntity( + vehicle, TeslemetryClimateSide.DRIVER, entry.runtime_data.scopes + ) + for vehicle in entry.runtime_data.vehicles + ),( + TeslemetryPollingCabinOverheatProtectionEntity( + vehicle, entry.runtime_data.scopes + ) + if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25" + else TeslemetryStreamingCabinOverheatProtectionEntity( + vehicle, entry.runtime_data.scopes + ) + for vehicle in entry.runtime_data.vehicles + )) + ) +DEFAULT_MIN_TEMP = 15 +DEFAULT_MAX_TEMP = 28 class TeslemetryClimateEntity(ClimateEntity): """Vehicle Climate Control.""" @@ -344,7 +351,7 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: self._attr_hvac_mode = hvac_mode self.async_write_ha_state() -class TeslemetryCabinOverheatProtectionPollingEntity(TeslemetryVehicleEntity, TeslemetryCabinOverheatProtectionEntity): +class TeslemetryPollingCabinOverheatProtectionEntity(TeslemetryVehicleEntity, TeslemetryCabinOverheatProtectionEntity): """Vehicle Cabin Overheat Protection.""" def __init__( @@ -387,7 +394,7 @@ def _async_update_attrs(self) -> None: self._attr_current_temperature = self.get("climate_state_inside_temp") -class TeslemetryCabinOverheatProtectionPollingEntity(TeslemetryVehicleComplexStreamEntity, TeslemetryCabinOverheatProtectionEntity): +class TeslemetryStreamingCabinOverheatProtectionEntity(TeslemetryVehicleComplexStreamEntity, TeslemetryCabinOverheatProtectionEntity): """Vehicle Cabin Overheat Protection.""" def __init__( diff --git a/custom_components/teslemetry/cover.py b/custom_components/teslemetry/cover.py index 8d05579..4728087 100644 --- a/custom_components/teslemetry/cover.py +++ b/custom_components/teslemetry/cover.py @@ -17,8 +17,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from .const import TeslemetryCoverStates, TeslemetryTimestamp -from .entity import TeslemetryVehicleEntity +from .entity import TeslemetryVehicleEntity, TeslemetryVehicleStreamEntity, TeslemetryVehicleComplexStreamEntity from .models import TeslemetryVehicleData from .helpers import auto_type @@ -33,44 +32,46 @@ async def async_setup_entry( async_add_entities( klass(vehicle, entry.runtime_data.scopes) + for vehicle in entry.runtime_data.vehicles for (klass) in ( - TeslemetryWindowEntity, + TeslemetryPollingWindowEntity if vehicle.pre2021 else TeslemetryStreamingWindowEntity, TeslemetryChargePortEntity, TeslemetryFrontTrunkEntity, TeslemetryRearTrunkEntity, TeslemetrySunroofEntity, ) - for vehicle in entry.runtime_data.vehicles - ) - -class CoverRestoreEntity(CoverEntity, RestoreEntity): - """Base class for cover entities that need to restore state.""" - - _attr_is_closed: bool | None = None - - async def async_added_to_hass(self) -> None: - """Handle entity which will be added.""" - await super().async_added_to_hass() - if (state := await self.async_get_last_state()) is not None and not self.coordinator.updated_once: - if (state.state == "open"): - self._attr_is_closed = False - elif (state.state == "closed"): - self._attr_is_closed = True - self._attr_current_cover_position = state.attributes.get("current_cover_position") + ) -class TeslemetryWindowEntity(TeslemetryVehicleEntity, CoverRestoreEntity): +class TeslemetryWindowEntity(CoverEntity): """Cover entity for windows.""" _attr_device_class = CoverDeviceClass.WINDOW _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + async def async_open_cover(self, **kwargs: Any) -> None: + """Vent windows.""" + self.raise_for_scope(Scope.VEHICLE_CMDS) + await self.wake_up_if_asleep() + await self.handle_command(self.api.window_control(command=WindowCommand.VENT)) + self._attr_is_closed = False + self.async_write_ha_state() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close windows.""" + self.raise_for_scope(Scope.VEHICLE_CMDS) + await self.wake_up_if_asleep() + await self.handle_command(self.api.window_control(command=WindowCommand.CLOSE)) + self._attr_is_closed = True + self.async_write_ha_state() + +class TeslemetryPollingWindowEntity(TeslemetryVehicleEntity, TeslemetryWindowEntity): + """Polling cover entity for windows.""" + def __init__(self, data: TeslemetryVehicleData, scopes: list[Scope]) -> None: """Initialize the sensor.""" - super().__init__( - data, "windows", timestamp_key=TeslemetryTimestamp.VEHICLE_STATE - ) + super().__init__(data, "windows") self.scoped = Scope.VEHICLE_CMDS in scopes if not self.scoped: self._attr_supported_features = CoverEntityFeature(0) @@ -82,38 +83,105 @@ def _async_update_attrs(self) -> None: rd = self.get("vehicle_state_rd_window") rp = self.get("vehicle_state_rp_window") - # Any open set to open if OPEN in (fd, fp, rd, rp): self._attr_is_closed = False - # All closed set to closed - elif CLOSED == fd == fp == rd == rp: - self._attr_is_closed = True - # Otherwise, set to unknown + elif None in (fd, fp, rd, rp): + self._attr_is_closed = None else: + self._attr_is_closed = True + +class TeslemetryStreamingWindowEntity(TeslemetryVehicleComplexStreamEntity, TeslemetryWindowEntity, RestoreEntity): + """Streaming cover entity for windows.""" + + fd: bool | None = None + fp: bool | None = None + rd: bool | None = None + rp: bool | None = None + + def __init__(self, data: TeslemetryVehicleData, scopes: list[Scope]) -> None: + """Initialize the sensor.""" + super().__init__(data, "windows", [ + Signal.FD_WINDOW, + Signal.FP_WINDOW, + Signal.RD_WINDOW, + Signal.RP_WINDOW, + ]) + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: + self._attr_supported_features = CoverEntityFeature(0) + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + if (state := await self.async_get_last_state()) is not None: + if (state.state == "open"): + self._attr_is_closed = False + elif (state.state == "closed"): + self._attr_is_closed = True + #self._attr_current_cover_position = state.attributes.get("current_cover_position") + + def _async_data_from_stream(self, data) -> None: + """Update the entity attributes.""" + if value := data.get(Signal.FD_WINDOW): + self.fd = value == "WindowStateOpen" + if value := data.get(Signal.FP_WINDOW): + self.fp = value == "WindowStateOpen" + if value := data.get(Signal.RD_WINDOW): + self.rd = value == "WindowStateOpen" + if value := data.get(Signal.RP_WINDOW): + self.rp = value == "WindowStateOpen" + + if True in (fd, fp, rd, rp): + self._attr_is_closed = False + elif None in (fd, fp, rd, rp): self._attr_is_closed = None + else: + self._attr_is_closed = True + +class TeslemetryChargePortEntity(CoverEntity): + """Cover entity for the charge port.""" + + _attr_device_class = CoverDeviceClass.DOOR + _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE async def async_open_cover(self, **kwargs: Any) -> None: - """Vent windows.""" - self.raise_for_scope(Scope.VEHICLE_CMDS) + """Open windows.""" + self.raise_for_scope(Scope.VEHICLE_CHARGING_CMDS) await self.wake_up_if_asleep() - await self.handle_command(self.api.window_control(command=WindowCommand.VENT)) + await self.handle_command(self.api.charge_port_door_open()) self._attr_is_closed = False self.async_write_ha_state() async def async_close_cover(self, **kwargs: Any) -> None: """Close windows.""" - self.raise_for_scope(Scope.VEHICLE_CMDS) + self.raise_for_scope(Scope.VEHICLE_CHARGING_CMDS) await self.wake_up_if_asleep() - await self.handle_command(self.api.window_control(command=WindowCommand.CLOSE)) + await self.handle_command(self.api.charge_port_door_close()) self._attr_is_closed = True self.async_write_ha_state() +class TeslemetryPollingChargePortLatch(TeslemetryVehicleEntity, TeslemetryChargePortEntity): + """Polling cover entity for the charge port.""" + def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: + """Initialize the sensor.""" + self.scoped = any( + scope in scopes + for scope in [Scope.VEHICLE_CMDS, Scope.VEHICLE_CHARGING_CMDS] + ) + if not self.scoped: + self._attr_supported_features = CoverEntityFeature(0) -class TeslemetryChargePortEntity(TeslemetryVehicleEntity, CoverRestoreEntity): - """Cover entity for the charge port.""" + super().__init__( + vehicle, + "charge_state_charge_port_door_open", + ) - _attr_device_class = CoverDeviceClass.DOOR - _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + def _async_value_from_stream(self, value) -> None: + """Update the value of the entity.""" + self._attr_is_closed = not auto_type(value) + +class TeslemetryStreamingChargePortLatch(TeslemetryVehicleStreamEntity, TeslemetryChargePortEntity): + """Streaming cover entity for the charge port.""" def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: """Initialize the sensor.""" @@ -127,34 +195,13 @@ def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: super().__init__( vehicle, "charge_state_charge_port_door_open", - timestamp_key=TeslemetryTimestamp.CHARGE_STATE, - streaming_key=Signal.CHARGE_PORT, + streaming_key=Signal.CHARGE_PORT_LATCH, ) - def _async_update_attrs(self) -> None: - """Update the entity attributes.""" - self._attr_is_closed = not self._value - def _async_value_from_stream(self, value) -> None: """Update the value of the entity.""" self._attr_is_closed = not auto_type(value) - async def async_open_cover(self, **kwargs: Any) -> None: - """Open windows.""" - self.raise_for_scope(Scope.VEHICLE_CHARGING_CMDS) - await self.wake_up_if_asleep() - await self.handle_command(self.api.charge_port_door_open()) - self._attr_is_closed = False - self.async_write_ha_state() - - async def async_close_cover(self, **kwargs: Any) -> None: - """Close windows.""" - self.raise_for_scope(Scope.VEHICLE_CHARGING_CMDS) - await self.wake_up_if_asleep() - await self.handle_command(self.api.charge_port_door_close()) - self._attr_is_closed = True - self.async_write_ha_state() - class TeslemetryFrontTrunkEntity(TeslemetryVehicleEntity, CoverRestoreEntity): """Cover entity for the front trunk.""" diff --git a/custom_components/teslemetry/entity.py b/custom_components/teslemetry/entity.py index c535418..4198afb 100644 --- a/custom_components/teslemetry/entity.py +++ b/custom_components/teslemetry/entity.py @@ -24,11 +24,27 @@ from .helpers import wake_up_vehicle, handle_command, handle_vehicle_command -class TeslemetryVehicleStreamEntity(Entity): - """Parent class for Teslemetry Vehicle Stream entities.""" +class TeslemetryEntity(Entity): + """Base class for all Teslemetry classes.""" _attr_has_entity_name = True + def raise_for_scope(self, scope: Scope): + """Raise an error if a scope is not available.""" + if not self.scoped: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="missing_scope", + translation_placeholders={"scope": scope}, + ) + + async def handle_command(self, command) -> dict[str, Any]: + """Handle a command.""" + return await handle_command(command) + +class TeslemetryVehicleStreamEntity(TeslemetryEntity): + """Parent class for Teslemetry Vehicle Stream entities.""" + def __init__( self, data: TeslemetryVehicleData, key: str, streaming_key: Signal ) -> None: @@ -68,11 +84,9 @@ def available(self) -> bool: """Return True if entity is available.""" return self.stream.connected -class TeslemetryVehicleComplexStreamEntity(Entity): +class TeslemetryVehicleComplexStreamEntity(TeslemetryEntity): """Parent class for Teslemetry Vehicle Stream entities with multiple keys.""" - _attr_has_entity_name = True - def __init__( self, data: TeslemetryVehicleData, key: str, streaming_keys: list[Signal] ) -> None: @@ -101,24 +115,23 @@ async def async_added_to_hass(self) -> None: def _handle_stream_update(self, data: dict[str, Any]) -> None: """Handle updated data from the stream.""" data = {key: data["data"][key] for key in self.streaming_keys if key in data["data"]} - self._async_value_from_stream(data["data"]) + self._async_data_from_stream(data["data"]) self.async_write_ha_state() def _async_data_from_stream(self, data: Any) -> None: """Update the entity with the latest value from the stream.""" raise NotImplementedError() -class TeslemetryEntity( +class TeslemetryCoordinatorEntity( CoordinatorEntity[ TeslemetryVehicleDataCoordinator | TeslemetryEnergySiteLiveCoordinator | TeslemetryEnergySiteInfoCoordinator | TeslemetryEnergyHistoryCoordinator - ] + ], + TeslemetryEntity, ): - """Parent class for all Teslemetry entities.""" - - _attr_has_entity_name = True + """Parent class for all polled Teslemetry entities.""" def __init__( self, @@ -168,18 +181,6 @@ def has(self, key: str | None = None) -> bool: """Return True if a specific value is in coordinator data.""" return (key or self.key) in self.coordinator.data - def raise_for_scope(self, scope: Scope): - """Raise an error if a scope is not available.""" - if not self.scoped: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="missing_scope", - translation_placeholders={"scope": scope}, - ) - - async def handle_command(self, command) -> dict[str, Any]: - """Handle a command.""" - return await handle_command(command) def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" @@ -192,8 +193,8 @@ def _async_update_attrs(self) -> None: raise NotImplementedError() -class TeslemetryVehicleEntity(TeslemetryEntity): - """Parent class for Teslemetry Vehicle entities.""" +class TeslemetryVehicleEntity(TeslemetryCoordinatorEntity): + """Parent class for polled Teslemetry Vehicle entities.""" def __init__( self, @@ -226,7 +227,7 @@ async def handle_command(self, command) -> dict[str, Any]: return await handle_vehicle_command(command) -class TeslemetryEnergyLiveEntity(TeslemetryEntity): +class TeslemetryEnergyLiveEntity(TeslemetryCoordinatorEntity): """Parent class for Teslemetry Energy Site Live entities.""" def __init__( @@ -243,7 +244,7 @@ def __init__( self._async_update_attrs() -class TeslemetryEnergyInfoEntity(TeslemetryEntity): +class TeslemetryEnergyInfoEntity(TeslemetryCoordinatorEntity): """Parent class for Teslemetry Energy Site Info Entities.""" def __init__( @@ -259,7 +260,7 @@ def __init__( super().__init__(data.info_coordinator, data.api, key) self._async_update_attrs() -class TeslemetryEnergyHistoryEntity(TeslemetryEntity): +class TeslemetryEnergyHistoryEntity(TeslemetryCoordinatorEntity): """Parent class for Teslemetry Energy History Entities.""" def __init__( @@ -272,12 +273,13 @@ def __init__( self._attr_device_info = data.device self._attr_translation_key = key + assert data.history_coordinator super().__init__(data.history_coordinator, data.api, key) self._async_update_attrs() class TeslemetryWallConnectorEntity( - TeslemetryEntity, CoordinatorEntity[TeslemetryEnergySiteLiveCoordinator] + TeslemetryCoordinatorEntity, CoordinatorEntity[TeslemetryEnergySiteLiveCoordinator] ): """Parent class for Teslemetry Wall Connector Entities."""