1
0
mirror of https://github.com/home-assistant/core.git synced 2026-06-30 19:26:31 +01:00
Files
core/tests/components/teslemetry/test_select.py
T
Brett Adams 5a99cb4cca Fix Teslemetry rear seat heater entity availability (#174248)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-22 20:54:55 +02:00

420 lines
15 KiB
Python

"""Test the Teslemetry select platform."""
from copy import deepcopy
from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from tesla_fleet_api.const import EnergyExportMode, EnergyOperationMode
from tesla_fleet_api.exceptions import InvalidCommand
from teslemetry_stream.const import Signal
from homeassistant.components.select import (
ATTR_OPTION,
DOMAIN as SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
)
from homeassistant.components.teslemetry.coordinator import ENERGY_INFO_INTERVAL
from homeassistant.components.teslemetry.select import LOW
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from . import assert_entities, reload_platform, setup_platform
from .const import COMMAND_ERRORS, COMMAND_OK, METADATA, SITE_INFO, VEHICLE_DATA_ALT
from tests.common import async_fire_time_changed
# Entity description keys for the rear seat-heater entities gated by config.
REAR_LEFT = "climate_state_seat_heater_rear_left"
REAR_CENTER = "climate_state_seat_heater_rear_center"
REAR_RIGHT = "climate_state_seat_heater_rear_right"
THIRD_LEFT = "climate_state_seat_heater_third_row_left"
THIRD_RIGHT = "climate_state_seat_heater_third_row_right"
ALL_REAR_KEYS = {REAR_LEFT, REAR_CENTER, REAR_RIGHT, THIRD_LEFT, THIRD_RIGHT}
VEHICLE_VIN = "LRW3F7EK4NC700000"
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_select(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_legacy: AsyncMock,
) -> None:
"""Tests that the select entities are correct."""
entry = await setup_platform(hass, [Platform.SELECT])
assert_entities(hass, entry.entry_id, entity_registry, snapshot)
@pytest.mark.parametrize(
("config", "expected"),
[
pytest.param({}, set(), id="no_config"),
pytest.param(
{"rear_seat_heaters": 0, "third_row_seats": "None"},
set(),
id="0_no_rear_heaters",
),
pytest.param(
{"rear_seat_heaters": 1, "third_row_seats": "None"},
{REAR_LEFT, REAR_CENTER, REAR_RIGHT},
id="1_heated_rear_bench",
),
pytest.param(
{"rear_seat_heaters": 2, "third_row_seats": "None"},
{REAR_LEFT, REAR_RIGHT},
id="2_legacy_model_s_outboard_only",
),
pytest.param(
{"rear_seat_heaters": 3, "third_row_seats": "FoldFlatPowerStrutSeats"},
{REAR_LEFT, REAR_CENTER, REAR_RIGHT, THIRD_LEFT, THIRD_RIGHT},
id="3_model_x_with_third_row",
),
pytest.param(
{"rear_seat_heaters": 3, "third_row_seats": "None"},
{REAR_LEFT, REAR_CENTER, REAR_RIGHT},
id="3_model_x_five_seat_no_third_row",
),
],
)
async def test_rear_seat_heater_configurations(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_metadata: AsyncMock,
config: dict[str, int | str],
expected: set[str],
) -> None:
"""Verify which rear seat-heater entities exist per rear_seat_heaters config.
rear_seat_heaters is an undocumented Tesla enum. Behaviour inferred from
vehicle models/years/config observed across the fleet:
0 - no heated rear seats
1 - heated rear bench: left, center, right (Model 3/Y, modern S/X, ...)
2 - outboard rear only: left, right, no center (classic Model S)
3 - heated rear bench plus third row (Model X)
Third-row heaters additionally require an actual third row, since some
5-seat Model X report 3 without having a third row. third_row_seats is a
string ("None" when absent), not a bool.
"""
metadata = deepcopy(METADATA)
metadata["vehicles"][VEHICLE_VIN]["config"] = config
mock_metadata.return_value = metadata
entry = await setup_platform(hass, [Platform.SELECT])
created = {
entity.unique_id.removeprefix(f"{VEHICLE_VIN}-")
for entity in er.async_entries_for_config_entry(entity_registry, entry.entry_id)
}
assert (created & ALL_REAR_KEYS) == expected
async def test_select_services(hass: HomeAssistant, mock_vehicle_data) -> None:
"""Tests that the select services work."""
mock_vehicle_data.return_value = VEHICLE_DATA_ALT
await setup_platform(hass, [Platform.SELECT])
entity_id = "select.test_seat_heater_front_left"
with patch(
"tesla_fleet_api.teslemetry.Vehicle.remote_seat_heater_request",
return_value=COMMAND_OK,
) as call:
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: entity_id, ATTR_OPTION: LOW},
blocking=True,
)
state = hass.states.get(entity_id)
assert state.state == LOW
call.assert_called_once()
entity_id = "select.test_steering_wheel_heater"
with patch(
"tesla_fleet_api.teslemetry.Vehicle.remote_steering_wheel_heat_level_request",
return_value=COMMAND_OK,
) as call:
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: entity_id, ATTR_OPTION: LOW},
blocking=True,
)
state = hass.states.get(entity_id)
assert state.state == LOW
call.assert_called_once()
entity_id = "select.energy_site_operation_mode"
with patch(
"tesla_fleet_api.teslemetry.EnergySite.operation",
return_value=COMMAND_OK,
) as call:
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{
ATTR_ENTITY_ID: entity_id,
ATTR_OPTION: EnergyOperationMode.AUTONOMOUS.value,
},
blocking=True,
)
state = hass.states.get(entity_id)
assert state.state == EnergyOperationMode.AUTONOMOUS.value
call.assert_called_once()
entity_id = "select.energy_site_allow_export"
with patch(
"tesla_fleet_api.teslemetry.EnergySite.grid_import_export",
return_value=COMMAND_OK,
) as call:
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: entity_id, ATTR_OPTION: EnergyExportMode.BATTERY_OK.value},
blocking=True,
)
state = hass.states.get(entity_id)
assert state.state == EnergyExportMode.BATTERY_OK.value
call.assert_called_once()
@pytest.mark.parametrize("response", COMMAND_ERRORS)
async def test_select_command_errors(
hass: HomeAssistant, mock_vehicle_data: AsyncMock, response: dict
) -> None:
"""Tests that vehicle command failures raise HomeAssistantError."""
mock_vehicle_data.return_value = VEHICLE_DATA_ALT
await setup_platform(hass, [Platform.SELECT])
with (
patch(
"tesla_fleet_api.teslemetry.Vehicle.remote_seat_heater_request",
return_value=response,
),
pytest.raises(HomeAssistantError),
):
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: "select.test_seat_heater_front_left", ATTR_OPTION: LOW},
blocking=True,
)
async def test_select_command_exception(hass: HomeAssistant) -> None:
"""Tests that an energy command SDK exception raises HomeAssistantError."""
await setup_platform(hass, [Platform.SELECT])
with (
patch(
"tesla_fleet_api.teslemetry.EnergySite.operation",
side_effect=InvalidCommand,
),
pytest.raises(HomeAssistantError),
):
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{
ATTR_ENTITY_ID: "select.energy_site_operation_mode",
ATTR_OPTION: EnergyOperationMode.AUTONOMOUS.value,
},
blocking=True,
)
async def test_select_invalid_data(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_vehicle_data: AsyncMock,
mock_legacy: AsyncMock,
) -> None:
"""Tests that the select entities handle invalid data."""
broken_data = VEHICLE_DATA_ALT.copy()
broken_data["response"]["climate_state"]["seat_heater_left"] = "green"
broken_data["response"]["climate_state"]["steering_wheel_heat_level"] = "yellow"
mock_vehicle_data.return_value = broken_data
await setup_platform(hass, [Platform.SELECT])
state = hass.states.get("select.test_seat_heater_front_left")
assert state.state == STATE_UNKNOWN
state = hass.states.get("select.test_steering_wheel_heater")
assert state.state == STATE_UNKNOWN
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_select_streaming(
hass: HomeAssistant,
mock_vehicle_data: AsyncMock,
mock_add_listener: AsyncMock,
) -> None:
"""Tests that the select entities with streaming are correct."""
entry = await setup_platform(hass, [Platform.SELECT])
# Stream update
mock_add_listener.send(
{
"vin": VEHICLE_DATA_ALT["response"]["vin"],
"data": {
Signal.SEAT_HEATER_LEFT: 0,
Signal.SEAT_HEATER_RIGHT: 1,
Signal.SEAT_HEATER_REAR_LEFT: 2,
Signal.SEAT_HEATER_REAR_RIGHT: 3,
Signal.HVAC_STEERING_WHEEL_HEAT_LEVEL: 0,
},
"createdAt": "2024-10-04T10:45:17.537Z",
}
)
await hass.async_block_till_done()
await reload_platform(hass, entry, [Platform.SELECT])
# Assert the entities restored their values with concrete assertions
assert hass.states.get("select.test_seat_heater_front_left").state == "off"
assert hass.states.get("select.test_seat_heater_front_right").state == "low"
assert hass.states.get("select.test_seat_heater_rear_left").state == "medium"
assert hass.states.get("select.test_seat_heater_rear_center").state == STATE_UNKNOWN
assert hass.states.get("select.test_seat_heater_rear_right").state == "high"
assert hass.states.get("select.test_steering_wheel_heater").state == "off"
async def test_export_rule_restore(
hass: HomeAssistant,
mock_site_info: AsyncMock,
) -> None:
"""Test export rule entity when value is missing due to VPP enrollment."""
# Mock energy site with missing export rule (VPP scenario)
vpp_site_info = deepcopy(SITE_INFO)
# Remove the customer_preferred_export_rule to simulate VPP enrollment
del vpp_site_info["response"]["components"]["customer_preferred_export_rule"]
mock_site_info.side_effect = lambda: vpp_site_info
# Set up platform
entry = await setup_platform(hass, [Platform.SELECT])
# Entity should exist but have no current option initially
entity_id = "select.energy_site_allow_export"
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_UNKNOWN
# Test service call works even when value is missing (VPP enrolled)
with patch(
"tesla_fleet_api.teslemetry.EnergySite.grid_import_export",
return_value=COMMAND_OK,
) as call:
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{
ATTR_ENTITY_ID: entity_id,
ATTR_OPTION: EnergyExportMode.BATTERY_OK.value,
},
blocking=True,
)
state = hass.states.get(entity_id)
assert state.state == EnergyExportMode.BATTERY_OK.value
call.assert_called_once()
# Reload the platform to test state restoration
await reload_platform(hass, entry, [Platform.SELECT])
# The entity should restore the previous state since API value is still missing
state = hass.states.get(entity_id)
assert state.state == EnergyExportMode.BATTERY_OK.value
@pytest.mark.parametrize(
("previous_data", "new_data", "expected_state"),
[
# Path 1: Customer selected export option (has value)
(
{
"customer_preferred_export_rule": "battery_ok",
"non_export_configured": None,
},
{
"customer_preferred_export_rule": "pv_only",
"non_export_configured": None,
},
EnergyExportMode.PV_ONLY.value,
),
# Path 2: In VPP, Export is disabled (non_export_configured is True)
(
{
"customer_preferred_export_rule": "battery_ok",
"non_export_configured": None,
},
{
"customer_preferred_export_rule": None,
"non_export_configured": True,
},
EnergyExportMode.NEVER.value,
),
# Path 3: In VPP, Export enabled but state shows disabled
# (current_option is NEVER)
(
{
"customer_preferred_export_rule": "never",
"non_export_configured": None,
},
{
"customer_preferred_export_rule": None,
"non_export_configured": None,
},
STATE_UNKNOWN,
),
# Path 4: In VPP Mode, Export isn't disabled, use last known state
(
{
"customer_preferred_export_rule": "battery_ok",
"non_export_configured": None,
},
{
"customer_preferred_export_rule": None,
"non_export_configured": None,
},
EnergyExportMode.BATTERY_OK.value,
),
],
)
async def test_export_rule_update_attrs_logic(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_site_info: AsyncMock,
previous_data: dict,
new_data: str | None,
expected_state: str,
) -> None:
"""Test all logic paths in TeslemetryExportRuleSelectEntity._async_update_attrs."""
# Create site info with the test data
test_site_info = deepcopy(SITE_INFO)
test_site_info["response"]["components"].update(previous_data)
mock_site_info.side_effect = lambda: test_site_info
# Set up platform
await setup_platform(hass, [Platform.SELECT])
# Change the state
test_site_info = deepcopy(SITE_INFO)
test_site_info["response"]["components"].update(new_data)
mock_site_info.side_effect = lambda: test_site_info
# Coordinator refresh
freezer.tick(ENERGY_INFO_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
# Check the final state matches expected
state = hass.states.get("select.energy_site_allow_export")
assert state
assert state.state == expected_state