diff --git a/homeassistant/components/tessie/entity.py b/homeassistant/components/tessie/entity.py index 4c077ce19db..42a3c92b2be 100644 --- a/homeassistant/components/tessie/entity.py +++ b/homeassistant/components/tessie/entity.py @@ -128,7 +128,7 @@ class TessieEnergyEntity(TessieBaseEntity): key: str, ) -> None: """Initialize common aspects of a Tessie energy site entity.""" - + self.api = data.api self._attr_unique_id = f"{data.id}-{key}" self._attr_device_info = data.device diff --git a/homeassistant/components/tessie/helpers.py b/homeassistant/components/tessie/helpers.py new file mode 100644 index 00000000000..41e619ac10d --- /dev/null +++ b/homeassistant/components/tessie/helpers.py @@ -0,0 +1,24 @@ +"""Tessie helper functions.""" + +from typing import Any + +from tesla_fleet_api.exceptions import TeslaFleetError + +from homeassistant.exceptions import HomeAssistantError + +from . import _LOGGER +from .const import DOMAIN + + +async def handle_command(command) -> dict[str, Any]: + """Handle a command.""" + try: + result = await command + except TeslaFleetError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_failed", + translation_placeholders={"message": e.message}, + ) from e + _LOGGER.debug("Command result: %s", result) + return result diff --git a/homeassistant/components/tessie/select.py b/homeassistant/components/tessie/select.py index 43af8161697..90e00084f15 100644 --- a/homeassistant/components/tessie/select.py +++ b/homeassistant/components/tessie/select.py @@ -2,6 +2,9 @@ from __future__ import annotations +from itertools import chain + +from tesla_fleet_api.const import EnergyExportMode, EnergyOperationMode from tessie_api import set_seat_heat from homeassistant.components.select import SelectEntity @@ -10,7 +13,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TessieConfigEntry from .const import TessieSeatHeaterOptions -from .entity import TessieEntity +from .entity import TessieEnergyEntity, TessieEntity +from .helpers import handle_command +from .models import TessieEnergyData SEAT_HEATERS = { "climate_state_seat_heater_left": "front_left", @@ -29,14 +34,28 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Tessie select platform from a config entry.""" - data = entry.runtime_data async_add_entities( - TessieSeatHeaterSelectEntity(vehicle, key) - for vehicle in data.vehicles - for key in SEAT_HEATERS - if key - in vehicle.data_coordinator.data # not all vehicles have rear center or third row + chain( + ( + TessieSeatHeaterSelectEntity(vehicle, key) + for vehicle in entry.runtime_data.vehicles + for key in SEAT_HEATERS + if key + in vehicle.data_coordinator.data # not all vehicles have rear center or third row + ), + ( + TessieOperationSelectEntity(energysite) + for energysite in entry.runtime_data.energysites + if energysite.info_coordinator.data.get("components_battery") + ), + ( + TessieExportRuleSelectEntity(energysite) + for energysite in entry.runtime_data.energysites + if energysite.info_coordinator.data.get("components_battery") + and energysite.info_coordinator.data.get("components_solar") + ), + ) ) @@ -60,3 +79,59 @@ class TessieSeatHeaterSelectEntity(TessieEntity, SelectEntity): level = self._attr_options.index(option) await self.run(set_seat_heat, seat=SEAT_HEATERS[self.key], level=level) self.set((self.key, level)) + + +class TessieOperationSelectEntity(TessieEnergyEntity, SelectEntity): + """Select entity for operation mode select entities.""" + + _attr_options: list[str] = [ + EnergyOperationMode.AUTONOMOUS, + EnergyOperationMode.BACKUP, + EnergyOperationMode.SELF_CONSUMPTION, + ] + + def __init__( + self, + data: TessieEnergyData, + ) -> None: + """Initialize the operation mode select entity.""" + super().__init__(data, data.info_coordinator, "default_real_mode") + + def _async_update_attrs(self) -> None: + """Update the attributes of the entity.""" + self._attr_current_option = self._value + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await handle_command(self.api.operation(option)) + self._attr_current_option = option + self.async_write_ha_state() + + +class TessieExportRuleSelectEntity(TessieEnergyEntity, SelectEntity): + """Select entity for export rules select entities.""" + + _attr_options: list[str] = [ + EnergyExportMode.NEVER, + EnergyExportMode.BATTERY_OK, + EnergyExportMode.PV_ONLY, + ] + + def __init__( + self, + data: TessieEnergyData, + ) -> None: + """Initialize the export rules select entity.""" + super().__init__( + data, data.info_coordinator, "components_customer_preferred_export_rule" + ) + + def _async_update_attrs(self) -> None: + """Update the attributes of the entity.""" + self._attr_current_option = self.get(self.key, EnergyExportMode.NEVER.value) + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await handle_command(self.api.grid_import_export(option)) + self._attr_current_option = option + self.async_write_ha_state() diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index d0a1be77b48..5c11730e2cd 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -300,6 +300,22 @@ "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" } + }, + "components_customer_preferred_export_rule": { + "name": "Allow export", + "state": { + "battery_ok": "Battery", + "never": "Never", + "pv_only": "Solar only" + } + }, + "default_real_mode": { + "name": "Operation mode", + "state": { + "autonomous": "Autonomous", + "backup": "Backup", + "self_consumption": "Self consumption" + } } }, "binary_sensor": { @@ -472,6 +488,9 @@ }, "no_cable": { "message": "Insert cable to lock" + }, + "command_failed": { + "message": "Command failed, {message}" } }, "issues": { diff --git a/tests/components/tessie/snapshots/test_select.ambr b/tests/components/tessie/snapshots/test_select.ambr index fc076aabf14..edd061a14e6 100644 --- a/tests/components/tessie/snapshots/test_select.ambr +++ b/tests/components/tessie/snapshots/test_select.ambr @@ -1,4 +1,118 @@ # serializer version: 1 +# name: test_select[select.energy_site_allow_export-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.energy_site_allow_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Allow export', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'components_customer_preferred_export_rule', + 'unique_id': '123456-components_customer_preferred_export_rule', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[select.energy_site_allow_export-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Energy Site Allow export', + 'options': list([ + , + , + , + ]), + }), + 'context': , + 'entity_id': 'select.energy_site_allow_export', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'pv_only', + }) +# --- +# name: test_select[select.energy_site_operation_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.energy_site_operation_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Operation mode', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'default_real_mode', + 'unique_id': '123456-default_real_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[select.energy_site_operation_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Energy Site Operation mode', + 'options': list([ + , + , + , + ]), + }), + 'context': , + 'entity_id': 'select.energy_site_operation_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'self_consumption', + }) +# --- # name: test_select[select.test_seat_heater_left-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tessie/test_select.py b/tests/components/tessie/test_select.py index f9526bf0a47..51645c75d47 100644 --- a/tests/components/tessie/test_select.py +++ b/tests/components/tessie/test_select.py @@ -4,6 +4,8 @@ from unittest.mock import patch import pytest from syrupy import SnapshotAssertion +from tesla_fleet_api.const import EnergyExportMode, EnergyOperationMode +from tesla_fleet_api.exceptions import UnsupportedVehicle from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, @@ -27,9 +29,8 @@ async def test_select( assert_entities(hass, entry.entry_id, entity_registry, snapshot) - entity_id = "select.test_seat_heater_left" - # Test changing select + entity_id = "select.test_seat_heater_left" with patch( "homeassistant.components.tessie.select.set_seat_heat", return_value=TEST_RESPONSE, @@ -45,14 +46,48 @@ async def test_select( assert mock_set.call_args[1]["level"] == 1 assert hass.states.get(entity_id) == snapshot(name=SERVICE_SELECT_OPTION) + # Test site operation mode + entity_id = "select.energy_site_operation_mode" + with patch( + "homeassistant.components.teslemetry.EnergySpecific.operation", + return_value=TEST_RESPONSE, + ) as call: + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: entity_id, + ATTR_OPTION: EnergyOperationMode.AUTONOMOUS.value, + }, + blocking=True, + ) + assert (state := hass.states.get(entity_id)) + assert state.state == EnergyOperationMode.AUTONOMOUS.value + call.assert_called_once() + + # Test site export mode + entity_id = "select.energy_site_allow_export" + with patch( + "homeassistant.components.teslemetry.EnergySpecific.grid_import_export", + return_value=TEST_RESPONSE, + ) 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, + ) + assert (state := hass.states.get(entity_id)) + assert state.state == EnergyExportMode.BATTERY_OK.value + call.assert_called_once() + async def test_errors(hass: HomeAssistant) -> None: """Tests unknown error is handled.""" await setup_platform(hass, [Platform.SELECT]) - entity_id = "select.test_seat_heater_left" - # Test setting cover open with unknown error + # Test changing vehicle select with unknown error with ( patch( "homeassistant.components.tessie.select.set_seat_heat", @@ -63,8 +98,31 @@ async def test_errors(hass: HomeAssistant) -> None: await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, - {ATTR_ENTITY_ID: [entity_id], ATTR_OPTION: TessieSeatHeaterOptions.LOW}, + { + ATTR_ENTITY_ID: ["select.test_seat_heater_left"], + ATTR_OPTION: TessieSeatHeaterOptions.LOW, + }, blocking=True, ) mock_set.assert_called_once() assert error.value.__cause__ == ERROR_UNKNOWN + + # Test changing energy select with unknown error + with ( + patch( + "homeassistant.components.tessie.EnergySpecific.operation", + side_effect=UnsupportedVehicle, + ) as mock_set, + pytest.raises(HomeAssistantError) as error, + ): + 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, + ) + mock_set.assert_called_once() + assert isinstance(error.value.__cause__, UnsupportedVehicle)