From 89a92be9d7b7d588ea655ad860282aaa802dccee Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 4 Jul 2026 02:49:18 +1000 Subject: [PATCH] Add seat coolers to Teslemetry (#175422) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/teslemetry/select.py | 27 ++++++++ .../components/teslemetry/strings.json | 18 ++++++ tests/components/teslemetry/test_select.py | 64 ++++++++++++++++++- 3 files changed, 108 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/teslemetry/select.py b/homeassistant/components/teslemetry/select.py index 2803c71ccf6..36aed04573f 100644 --- a/homeassistant/components/teslemetry/select.py +++ b/homeassistant/components/teslemetry/select.py @@ -176,6 +176,33 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySelectEntityDescription, ...] = ( 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, + ], + ), ) diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index ffb9e9d9ccb..cc39dad96be 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -363,6 +363,24 @@ } }, "select": { + "climate_state_seat_fan_front_left": { + "name": "Seat cooler front left", + "state": { + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" + } + }, + "climate_state_seat_fan_front_right": { + "name": "Seat cooler front right", + "state": { + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" + } + }, "climate_state_seat_heater_left": { "name": "Seat heater front left", "state": { diff --git a/tests/components/teslemetry/test_select.py b/tests/components/teslemetry/test_select.py index a7e58d525ba..7c2ec7a34f6 100644 --- a/tests/components/teslemetry/test_select.py +++ b/tests/components/teslemetry/test_select.py @@ -16,7 +16,7 @@ from homeassistant.components.select import ( SERVICE_SELECT_OPTION, ) from homeassistant.components.teslemetry.coordinator import ENERGY_INFO_INTERVAL -from homeassistant.components.teslemetry.select import LOW +from homeassistant.components.teslemetry.select import LEVEL, LOW, MEDIUM, OFF from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -183,6 +183,68 @@ async def test_select_services(hass: HomeAssistant, mock_vehicle_data) -> None: 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