Files

443 lines
15 KiB
Python

"""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()