"""Test the Teslemetry select platform.""" from collections.abc import Awaitable, Callable 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, VEHICLE_INTERVAL, ) from homeassistant.components.teslemetry.select import HIGH, LEVEL, LOW, MEDIUM, OFF 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, METADATA_LEGACY, 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( ("entity_id", "seat_position"), [ ("select.test_seat_cooler_front_left", 1), ("select.test_seat_cooler_front_right", 2), ], ) async def test_seat_cooler_services( hass: HomeAssistant, mock_metadata: AsyncMock, mock_vehicle_data: AsyncMock, entity_id: str, seat_position: int, ) -> None: """Test the seat cooler entities send the 1-indexed seat position. remote_seat_cooler_request is 1-indexed (front-left=1, front-right=2), unlike the 0-indexed Seat enum used for the seat heaters. """ mock_vehicle_data.return_value = VEHICLE_DATA_ALT metadata = deepcopy(METADATA) metadata["vehicles"][VEHICLE_VIN]["config"] = {"has_seat_cooling": True} mock_metadata.return_value = metadata await setup_platform(hass, [Platform.SELECT]) with patch( "tesla_fleet_api.teslemetry.Vehicle.remote_seat_cooler_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, ) assert hass.states.get(entity_id).state == LOW call.assert_called_once_with(seat_position, LEVEL[LOW]) async def test_seat_cooler_polling( hass: HomeAssistant, mock_metadata: AsyncMock, mock_vehicle_data: AsyncMock, ) -> None: """Test the seat cooler entities read polled state from seat_fan_front_*.""" metadata = deepcopy(METADATA) metadata["vehicles"][VEHICLE_VIN]["polling"] = True metadata["vehicles"][VEHICLE_VIN]["config"] = {"has_seat_cooling": True} mock_metadata.return_value = metadata data = deepcopy(VEHICLE_DATA_ALT) data["response"]["climate_state"]["seat_fan_front_left"] = 2 data["response"]["climate_state"]["seat_fan_front_right"] = 0 mock_vehicle_data.return_value = data await setup_platform(hass, [Platform.SELECT]) assert hass.states.get("select.test_seat_cooler_front_left").state == MEDIUM assert hass.states.get("select.test_seat_cooler_front_right").state == OFF @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 _drive_polling( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_vehicle_data: AsyncMock, mock_add_listener: AsyncMock, value: int, ) -> None: """Push a steering wheel level through the polling path.""" data = deepcopy(VEHICLE_DATA_ALT) data["response"]["climate_state"]["steering_wheel_heat_level"] = value mock_vehicle_data.return_value = data freezer.tick(VEHICLE_INTERVAL) async_fire_time_changed(hass) async def _drive_streaming( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_vehicle_data: AsyncMock, mock_add_listener: AsyncMock, value: int, ) -> None: """Push a steering wheel level through the streaming path.""" mock_add_listener.send( { "vin": VEHICLE_DATA_ALT["response"]["vin"], "data": {Signal.HVAC_STEERING_WHEEL_HEAT_LEVEL: value}, "createdAt": "2024-10-04T10:45:17.537Z", } ) @pytest.mark.parametrize( ("metadata", "driver"), [ pytest.param(METADATA_LEGACY, _drive_polling, id="polling"), pytest.param(METADATA, _drive_streaming, id="streaming"), ], ) @pytest.mark.parametrize( ("value", "expected"), [ pytest.param(2, HIGH, id="level_2_clamped"), pytest.param(3, HIGH, id="level_3_clamped"), ], ) async def test_steering_wheel_heat_levels( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_vehicle_data: AsyncMock, mock_metadata: AsyncMock, mock_add_listener: AsyncMock, metadata: dict, driver: Callable[ [HomeAssistant, FrozenDateTimeFactory, AsyncMock, AsyncMock, int], Awaitable[None], ], value: int, expected: str, ) -> None: """A level beyond the last modeled option clamps to that option, high.""" freezer.move_to("2024-01-01 00:00:00+00:00") mock_metadata.return_value = metadata await setup_platform(hass, [Platform.SELECT]) await driver(hass, freezer, mock_vehicle_data, mock_add_listener, value) await hass.async_block_till_done() state = hass.states.get("select.test_steering_wheel_heater") assert state.state == expected 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