"""Select platform for Teslemetry integration.""" from collections.abc import Awaitable, Callable from dataclasses import dataclass from itertools import chain from typing import Any, override from tesla_fleet_api import firmware_at_least from tesla_fleet_api.const import EnergyExportMode, EnergyOperationMode, Scope, Seat from tesla_fleet_api.teslemetry import Vehicle from teslemetry_stream import TeslemetryStreamVehicle from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from . import TeslemetryConfigEntry from .entity import ( TeslemetryEnergyInfoEntity, TeslemetryRootEntity, TeslemetryVehiclePollingEntity, TeslemetryVehicleStreamEntity, ) from .helpers import handle_command, handle_vehicle_command from .models import TeslemetryEnergyData, TeslemetryVehicleData OFF = "off" LOW = "low" MEDIUM = "medium" HIGH = "high" PARALLEL_UPDATES = 0 LEVEL = {OFF: 0, LOW: 1, MEDIUM: 2, HIGH: 3} @dataclass(frozen=True, kw_only=True) class TeslemetrySelectEntityDescription(SelectEntityDescription): """Seat Heater entity description.""" select_fn: Callable[[Vehicle, int], Awaitable[Any]] supported_fn: Callable[[dict], bool] = lambda _: True streaming_listener: ( Callable[ [TeslemetryStreamVehicle, Callable[[int | None], None]], Callable[[], None], ] | None ) = None options: list[str] VEHICLE_DESCRIPTIONS: tuple[TeslemetrySelectEntityDescription, ...] = ( TeslemetrySelectEntityDescription( key="climate_state_seat_heater_left", select_fn=lambda api, level: api.remote_seat_heater_request( Seat.FRONT_LEFT, level ), streaming_listener=lambda x, y: x.listen_SeatHeaterLeft(y), options=[ OFF, LOW, MEDIUM, HIGH, ], ), TeslemetrySelectEntityDescription( key="climate_state_seat_heater_right", select_fn=lambda api, level: api.remote_seat_heater_request( Seat.FRONT_RIGHT, level ), streaming_listener=lambda x, y: x.listen_SeatHeaterRight(y), options=[ OFF, LOW, MEDIUM, HIGH, ], ), TeslemetrySelectEntityDescription( key="climate_state_seat_heater_rear_left", select_fn=lambda api, level: api.remote_seat_heater_request( Seat.REAR_LEFT, level ), supported_fn=lambda data: bool(data.get("rear_seat_heaters")), streaming_listener=lambda x, y: x.listen_SeatHeaterRearLeft(y), entity_registry_enabled_default=False, options=[ OFF, LOW, MEDIUM, HIGH, ], ), TeslemetrySelectEntityDescription( key="climate_state_seat_heater_rear_center", select_fn=lambda api, level: api.remote_seat_heater_request( Seat.REAR_CENTER, level ), # Center is heated only on the full-bench configs (1, 3); value 2 is # outboard-only rear heating (no center), seen on classic Model S. supported_fn=lambda data: data.get("rear_seat_heaters") in (1, 3), streaming_listener=lambda x, y: x.listen_SeatHeaterRearCenter(y), entity_registry_enabled_default=False, options=[ OFF, LOW, MEDIUM, HIGH, ], ), TeslemetrySelectEntityDescription( key="climate_state_seat_heater_rear_right", select_fn=lambda api, level: api.remote_seat_heater_request( Seat.REAR_RIGHT, level ), supported_fn=lambda data: bool(data.get("rear_seat_heaters")), streaming_listener=lambda x, y: x.listen_SeatHeaterRearRight(y), entity_registry_enabled_default=False, options=[ OFF, LOW, MEDIUM, HIGH, ], ), TeslemetrySelectEntityDescription( key="climate_state_seat_heater_third_row_left", select_fn=lambda api, level: api.remote_seat_heater_request( Seat.THIRD_LEFT, level ), # Heated third row only on Model X (value 3) that actually has a third # row; some 5-seat Model X also report 3 but have no third row. # third_row_seats is a string ("None" when absent), not a bool. supported_fn=lambda data: ( data.get("rear_seat_heaters") == 3 and data.get("third_row_seats", "None") != "None" ), entity_registry_enabled_default=False, options=[ OFF, LOW, MEDIUM, HIGH, ], ), TeslemetrySelectEntityDescription( key="climate_state_seat_heater_third_row_right", select_fn=lambda api, level: api.remote_seat_heater_request( Seat.THIRD_RIGHT, level ), # Heated third row only on Model X (value 3) that actually has a third # row; some 5-seat Model X also report 3 but have no third row. # third_row_seats is a string ("None" when absent), not a bool. supported_fn=lambda data: ( data.get("rear_seat_heaters") == 3 and data.get("third_row_seats", "None") != "None" ), entity_registry_enabled_default=False, options=[ OFF, LOW, MEDIUM, HIGH, ], ), TeslemetrySelectEntityDescription( key="climate_state_steering_wheel_heat_level", select_fn=lambda api, level: api.remote_steering_wheel_heat_level_request( level ), streaming_listener=lambda x, y: x.listen_HvacSteeringWheelHeatLevel(y), options=[ OFF, LOW, HIGH, ], ), TeslemetrySelectEntityDescription( # remote_seat_cooler_request uses 1-indexed positions (front-left=1, # front-right=2), unlike the 0-indexed Seat enum used for heaters. # Polled state comes from the seat_fan_front_* vehicle_data fields. key="climate_state_seat_fan_front_left", select_fn=lambda api, level: api.remote_seat_cooler_request(1, level), supported_fn=lambda data: bool(data.get("has_seat_cooling")), streaming_listener=lambda x, y: x.listen_ClimateSeatCoolingFrontLeft(y), options=[ OFF, LOW, MEDIUM, HIGH, ], ), TeslemetrySelectEntityDescription( key="climate_state_seat_fan_front_right", select_fn=lambda api, level: api.remote_seat_cooler_request(2, level), supported_fn=lambda data: bool(data.get("has_seat_cooling")), streaming_listener=lambda x, y: x.listen_ClimateSeatCoolingFrontRight(y), options=[ OFF, LOW, MEDIUM, HIGH, ], ), ) async def async_setup_entry( hass: HomeAssistant, entry: TeslemetryConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Teslemetry select platform from a config entry.""" async_add_entities( chain( ( TeslemetryVehiclePollingSelectEntity( vehicle, description, entry.runtime_data.scopes ) if vehicle.poll or not firmware_at_least(vehicle.firmware, "2024.26") or description.streaming_listener is None else TeslemetryStreamingSelectEntity( vehicle, description, entry.runtime_data.scopes ) for description in VEHICLE_DESCRIPTIONS for vehicle in entry.runtime_data.vehicles if description.supported_fn( entry.runtime_data.metadata_coordinator.data.get("vehicles", {}) .get(vehicle.vin, {}) .get("config", {}) ) ), ( TeslemetryOperationSelectEntity(energysite, entry.runtime_data.scopes) for energysite in entry.runtime_data.energysites if energysite.info_coordinator.data.get("components_battery") ), ( TeslemetryExportRuleSelectEntity(energysite, entry.runtime_data.scopes) for energysite in entry.runtime_data.energysites if energysite.info_coordinator.data.get("components_battery") and energysite.info_coordinator.data.get("components_solar") ), ) ) class TeslemetrySelectEntity(TeslemetryRootEntity, SelectEntity): """Parent vehicle select entity class.""" api: Vehicle entity_description: TeslemetrySelectEntityDescription _climate: bool = False @override async def async_select_option(self, option: str) -> None: """Change the selected option.""" self.raise_for_scope(Scope.VEHICLE_CMDS) level = LEVEL[option] # AC must be on to turn on heaters if level and not self._climate: await handle_vehicle_command(self.api.auto_conditioning_start()) await handle_vehicle_command(self.entity_description.select_fn(self.api, level)) self._attr_current_option = option self.async_write_ha_state() class TeslemetryVehiclePollingSelectEntity( TeslemetryVehiclePollingEntity, TeslemetrySelectEntity ): """Base polling vehicle select entity class.""" def __init__( self, data: TeslemetryVehicleData, description: TeslemetrySelectEntityDescription, scopes: list[Scope], ) -> None: """Initialize the vehicle seat select entity.""" self.entity_description = description self.scoped = Scope.VEHICLE_CMDS in scopes super().__init__(data, description.key) @override def _async_update_attrs(self) -> None: """Handle updated data from the coordinator.""" self._climate = bool(self.get("climate_state_is_climate_on")) value = self._value # Defensive clamp: Tesla could report a level outside the modeled # range, so map it to the nearest known option rather than erroring. if isinstance(value, int): options = self.entity_description.options self._attr_current_option = options[max(0, min(value, len(options) - 1))] else: self._attr_current_option = None class TeslemetryStreamingSelectEntity( TeslemetryVehicleStreamEntity, TeslemetrySelectEntity, RestoreEntity ): """Base streaming vehicle select entity class.""" def __init__( self, data: TeslemetryVehicleData, description: TeslemetrySelectEntityDescription, scopes: list[Scope], ) -> None: """Initialize the vehicle seat select entity.""" self.entity_description = description self.scoped = Scope.VEHICLE_CMDS in scopes self._attr_current_option = None super().__init__(data, description.key) @override async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() # Restore state if (state := await self.async_get_last_state()) is not None: if state.state in self.entity_description.options: self._attr_current_option = state.state # Listen for streaming data assert self.entity_description.streaming_listener is not None self.async_on_remove( self.entity_description.streaming_listener( self.vehicle.stream_vehicle, self._value_callback ) ) self.async_on_remove( self.vehicle.stream_vehicle.listen_HvacACEnabled(self._climate_callback) ) def _value_callback(self, value: int | None) -> None: """Update the value of the entity.""" # Defensive clamp: Tesla could report a level outside the modeled # range, so map it to the nearest known option rather than erroring. if isinstance(value, int): options = self.entity_description.options self._attr_current_option = options[max(0, min(value, len(options) - 1))] else: self._attr_current_option = None self.async_write_ha_state() def _climate_callback(self, value: bool | None) -> None: """Update the value of the entity.""" self._climate = bool(value) class TeslemetryOperationSelectEntity(TeslemetryEnergyInfoEntity, SelectEntity): """Select entity for operation mode select entities.""" _attr_options: list[str] = [ EnergyOperationMode.AUTONOMOUS, EnergyOperationMode.BACKUP, EnergyOperationMode.SELF_CONSUMPTION, ] def __init__( self, data: TeslemetryEnergyData, scopes: list[Scope], ) -> None: """Initialize the operation mode select entity.""" self.scoped = Scope.ENERGY_CMDS in scopes super().__init__(data, "default_real_mode") @override def _async_update_attrs(self) -> None: """Update the attributes of the entity.""" self._attr_current_option = self._value @override async def async_select_option(self, option: str) -> None: """Change the selected option.""" self.raise_for_scope(Scope.ENERGY_CMDS) await handle_command(self.api.operation(option)) self._attr_current_option = option self.async_write_ha_state() class TeslemetryExportRuleSelectEntity( TeslemetryEnergyInfoEntity, SelectEntity, RestoreEntity ): """Select entity for export rules select entities.""" _attr_options: list[str] = [ EnergyExportMode.NEVER, EnergyExportMode.BATTERY_OK, EnergyExportMode.PV_ONLY, ] def __init__( self, data: TeslemetryEnergyData, scopes: list[Scope], ) -> None: """Initialize the export rules select entity.""" self.scoped = Scope.ENERGY_CMDS in scopes super().__init__(data, "components_customer_preferred_export_rule") @override async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() # Restore state if it's not known if self._attr_current_option is None: if (state := await self.async_get_last_state()) is not None: if state.state in self._attr_options: self._attr_current_option = state.state @override def _async_update_attrs(self) -> None: """Update the attributes of the entity.""" if value := self._value: # Customer selected export option self._attr_current_option = value elif self.get("components_non_export_configured") is True: # In VPP, Export is disabled self._attr_current_option = EnergyExportMode.NEVER elif self._attr_current_option == EnergyExportMode.NEVER: # In VPP, Export is enabled, but our state shows it is disabled self._attr_current_option = None # Unknown # In VPP Mode, Export isn't disabled, so use last known state @override async def async_select_option(self, option: str) -> None: """Change the selected option.""" self.raise_for_scope(Scope.ENERGY_CMDS) await handle_command( self.api.grid_import_export(customer_preferred_export_rule=option) ) self._attr_current_option = option self.async_write_ha_state()