Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 54 additions & 2 deletions homeassistant/components/teslemetry/select.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@
from teslemetry_stream import TeslemetryStreamVehicle

from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity

from . import TeslemetryConfigEntry
from .ble import TeslemetryVehicleBluetoothEntity
from .entity import (
TeslemetryEnergyInfoEntity,
TeslemetryRootEntity,
Expand Down Expand Up @@ -49,6 +50,7 @@ class TeslemetrySelectEntityDescription(SelectEntityDescription):
]
| None
) = None
bluetooth_field: str | None = None
options: list[str]


Expand Down Expand Up @@ -138,6 +140,7 @@ class TeslemetrySelectEntityDescription(SelectEntityDescription):
data.get("rear_seat_heaters") == 3
and data.get("third_row_seats", "None") != "None"
),
bluetooth_field="seat_heater_third_row_left",
entity_registry_enabled_default=False,
options=[
OFF,
Expand All @@ -158,6 +161,7 @@ class TeslemetrySelectEntityDescription(SelectEntityDescription):
data.get("rear_seat_heaters") == 3
and data.get("third_row_seats", "None") != "None"
),
bluetooth_field="seat_heater_third_row_right",
entity_registry_enabled_default=False,
options=[
OFF,
Expand Down Expand Up @@ -218,7 +222,11 @@ async def async_setup_entry(
async_add_entities(
chain(
(
TeslemetryVehiclePollingSelectEntity(
TeslemetryBluetoothSelectEntity(
vehicle, description, entry.runtime_data.scopes
)
if vehicle.ble is not None and description.bluetooth_field is not None
else TeslemetryVehiclePollingSelectEntity(
vehicle, description, entry.runtime_data.scopes
)
if vehicle.poll
Expand Down Expand Up @@ -355,6 +363,50 @@ def _climate_callback(self, value: bool | None) -> None:
self._climate = bool(value)


class TeslemetryBluetoothSelectEntity(
TeslemetryVehicleBluetoothEntity, TeslemetrySelectEntity
):
"""Vehicle seat select entity read over a parked climate INFO snapshot."""

_attr_current_option: str | None = None

def __init__(
self,
data: TeslemetryVehicleData,
description: TeslemetrySelectEntityDescription,
scopes: list[Scope],
) -> None:
"""Initialize the vehicle seat select entity."""
self.entity_description = description
super().__init__(data, description.key)
self.scoped = Scope.VEHICLE_CMDS in scopes

@override
async def async_added_to_hass(self) -> None:
"""Register the parked climate INFO endpoint for the seat heater."""
await super().async_added_to_hass()
self.async_on_remove(
self.manager.async_on_endpoint(
"climate",
lambda ble: ble.climate_state(),
self._handle_endpoint,
)
)

@callback
def _handle_endpoint(self, value: Any, generation: int) -> None:
"""Render the seat heater level from a climate snapshot."""
self._value = value
self._generation = generation
if value is not None:
self._climate = value.is_climate_on
assert self.entity_description.bluetooth_field is not None
level = getattr(value, self.entity_description.bluetooth_field)
options = self.entity_description.options
self._attr_current_option = options[max(0, min(level, len(options) - 1))]
self.async_write_ha_state()


class TeslemetryOperationSelectEntity(TeslemetryEnergyInfoEntity, SelectEntity):
"""Select entity for operation mode select entities."""

Expand Down
128 changes: 125 additions & 3 deletions tests/components/teslemetry/test_ble.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
)

# pylint: disable-next=no-name-in-module
from tesla_fleet_api.tesla.vehicle.proto.vehicle_pb2 import ClosuresState
from tesla_fleet_api.tesla.vehicle.proto.vehicle_pb2 import ClimateState, ClosuresState
from teslemetry_stream import Signal

from homeassistant.components.lock import LockState
Expand All @@ -38,7 +38,7 @@
from homeassistant.util import dt as dt_util

from . import mock_config_entry, setup_platform
from .const import PRODUCTS
from .const import METADATA, PRODUCTS

from tests.common import MockConfigEntry, async_fire_time_changed

Expand Down Expand Up @@ -71,6 +71,7 @@ async def _setup_ble(
connected: bool = True,
platforms: tuple[Platform, ...] = (Platform.BINARY_SENSOR,),
sunroof: bool = False,
third_row: bool = False,
) -> tuple[MockConfigEntry, MagicMock]:
"""Set up the given platforms for a BLE-paired vehicle.

Expand All @@ -79,7 +80,7 @@ async def _setup_ble(
``listen_connection_status`` records the manager's connection callback. When
``connected`` the link is brought up via that callback, as the library does
once a session is established. ``sunroof`` reports the vehicle's metadata as
sunroof-installed.
sunroof-installed; ``third_row`` reports it as a heated-third-row Model X.
"""
entry = _entry_with_ble()
entry.add_to_hass(hass)
Expand All @@ -91,6 +92,12 @@ async def _setup_ble(
if sunroof:
products["response"][0]["vehicle_config"]["sun_roof_installed"] = True

metadata = deepcopy(METADATA)
if third_row:
config = metadata["vehicles"][VIN]["config"]
config["rear_seat_heaters"] = 3
config["third_row_seats"] = "FoldFlatWithLatch"

with (
patch(
"homeassistant.components.teslemetry.async_ble_device_from_address",
Expand All @@ -101,6 +108,7 @@ async def _setup_ble(
) as mock_parent,
patch("homeassistant.components.teslemetry.PLATFORMS", list(platforms)),
patch("tesla_fleet_api.teslemetry.Teslemetry.products", return_value=products),
patch("tesla_fleet_api.teslemetry.Teslemetry.metadata", return_value=metadata),
):
mock_parent.return_value.get_private_key = AsyncMock()
mock_parent.return_value.vehicles.createBluetooth.return_value = (
Expand Down Expand Up @@ -624,3 +632,117 @@ async def test_sunroof_absent_makes_no_reads(
await hass.async_block_till_done()

bluetooth.closures_state.assert_not_called()


def _climate(left: int = 0, right: int = 0, climate_on: bool = True) -> ClimateState:
"""Build a BLE climate snapshot with third-row heater levels."""
return ClimateState(
seat_heater_third_row_left=left,
seat_heater_third_row_right=right,
is_climate_on=climate_on,
)


@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_third_row_reads_over_climate(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_add_listener: MagicMock,
) -> None:
"""A heated-third-row Model X reads its levels over one climate snapshot."""
_entry, bluetooth = await _setup_ble(
hass,
connected=True,
platforms=(Platform.COVER, Platform.SELECT),
third_row=True,
)
left_id = entity_registry.async_get_entity_id(
"select", "teslemetry", f"{VIN}-climate_state_seat_heater_third_row_left"
)
assert left_id is not None
assert hass.states.get(left_id).state == STATE_UNAVAILABLE

bluetooth.climate_state = AsyncMock(return_value=_climate(left=2))
_make_active(bluetooth, mock_add_listener)
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=6))
await hass.async_block_till_done()

bluetooth.climate_state.assert_awaited_once()
bluetooth.vehicle_data.assert_not_called()
assert hass.states.get(left_id).state == "medium"


@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_third_row_and_sunroof_read_sequentially(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_add_listener: MagicMock,
) -> None:
"""Two configured endpoints each read once, never as an aggregate call."""
_entry, bluetooth = await _setup_ble(
hass,
connected=True,
platforms=(Platform.COVER, Platform.SELECT),
sunroof=True,
third_row=True,
)
left_id = entity_registry.async_get_entity_id(
"select", "teslemetry", f"{VIN}-climate_state_seat_heater_third_row_left"
)
sunroof_id = entity_registry.async_get_entity_id(
"cover", "teslemetry", f"{VIN}-vehicle_state_sun_roof_state"
)

bluetooth.climate_state = AsyncMock(return_value=_climate(left=3))
bluetooth.closures_state = AsyncMock(return_value=_closures("Closed"))
_make_active(bluetooth, mock_add_listener)
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=6))
await hass.async_block_till_done()

bluetooth.climate_state.assert_awaited_once()
bluetooth.closures_state.assert_awaited_once()
bluetooth.vehicle_data.assert_not_called()
assert hass.states.get(left_id).state == "high"
assert hass.states.get(sunroof_id).state == STATE_CLOSED


@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_third_row_absent_no_entity(
hass: HomeAssistant, entity_registry: er.EntityRegistry
) -> None:
"""A vehicle without a heated third row has no third-row select entity."""
await _setup_ble(hass, platforms=(Platform.SELECT,), third_row=False)
assert (
entity_registry.async_get_entity_id(
"select", "teslemetry", f"{VIN}-climate_state_seat_heater_third_row_left"
)
is None
)


@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_third_row_link_loss_marks_unavailable(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_add_listener: MagicMock,
) -> None:
"""The third-row select goes unavailable on link loss, no cloud fallback."""
_entry, bluetooth = await _setup_ble(
hass,
connected=True,
platforms=(Platform.COVER, Platform.SELECT),
third_row=True,
)
left_id = entity_registry.async_get_entity_id(
"select", "teslemetry", f"{VIN}-climate_state_seat_heater_third_row_left"
)

bluetooth.climate_state = AsyncMock(return_value=_climate(left=1))
_make_active(bluetooth, mock_add_listener)
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=6))
await hass.async_block_till_done()
assert hass.states.get(left_id).state == "low"

_emit_connection(bluetooth, False)
await hass.async_block_till_done()
assert hass.states.get(left_id).state == STATE_UNAVAILABLE
Loading