diff --git a/homeassistant/components/wallbox/__init__.py b/homeassistant/components/wallbox/__init__.py index fc8c6e00e84..9336ab0e36b 100644 --- a/homeassistant/components/wallbox/__init__.py +++ b/homeassistant/components/wallbox/__init__.py @@ -12,7 +12,13 @@ from homeassistant.exceptions import ConfigEntryAuthFailed from .const import DOMAIN, UPDATE_INTERVAL from .coordinator import InvalidAuth, WallboxCoordinator, async_validate_input -PLATFORMS = [Platform.LOCK, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [ + Platform.LOCK, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, +] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/wallbox/const.py b/homeassistant/components/wallbox/const.py index c38b8967776..dfa7fd5a4c1 100644 --- a/homeassistant/components/wallbox/const.py +++ b/homeassistant/components/wallbox/const.py @@ -38,6 +38,9 @@ CHARGER_STATE_OF_CHARGE_KEY = "state_of_charge" CHARGER_STATUS_ID_KEY = "status_id" CHARGER_STATUS_DESCRIPTION_KEY = "status_description" CHARGER_CONNECTIONS = "connections" +CHARGER_ECO_SMART_KEY = "ecosmart" +CHARGER_ECO_SMART_STATUS_KEY = "enabled" +CHARGER_ECO_SMART_MODE_KEY = "mode" class ChargerStatus(StrEnum): @@ -61,3 +64,11 @@ class ChargerStatus(StrEnum): WAITING_MID_SAFETY = "Waiting MID safety margin exceeded" WAITING_IN_QUEUE_ECO_SMART = "Waiting in queue by Eco-Smart" UNKNOWN = "Unknown" + + +class EcoSmartMode(StrEnum): + """Charger Eco mode select options.""" + + OFF = "off" + ECO_MODE = "eco_mode" + FULL_SOLAR = "full_solar" diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index 4f20f5c406d..60f062e57cc 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -19,6 +19,9 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( CHARGER_CURRENCY_KEY, CHARGER_DATA_KEY, + CHARGER_ECO_SMART_KEY, + CHARGER_ECO_SMART_MODE_KEY, + CHARGER_ECO_SMART_STATUS_KEY, CHARGER_ENERGY_PRICE_KEY, CHARGER_FEATURES_KEY, CHARGER_LOCKED_UNLOCKED_KEY, @@ -33,6 +36,7 @@ from .const import ( DOMAIN, UPDATE_INTERVAL, ChargerStatus, + EcoSmartMode, ) _LOGGER = logging.getLogger(__name__) @@ -160,6 +164,21 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): data[CHARGER_STATUS_DESCRIPTION_KEY] = CHARGER_STATUS.get( data[CHARGER_STATUS_ID_KEY], ChargerStatus.UNKNOWN ) + + # Set current solar charging mode + eco_smart_enabled = data[CHARGER_DATA_KEY][CHARGER_ECO_SMART_KEY][ + CHARGER_ECO_SMART_STATUS_KEY + ] + eco_smart_mode = data[CHARGER_DATA_KEY][CHARGER_ECO_SMART_KEY][ + CHARGER_ECO_SMART_MODE_KEY + ] + if eco_smart_enabled is False: + data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.OFF + elif eco_smart_mode == 0: + data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.ECO_MODE + elif eco_smart_mode == 1: + data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.FULL_SOLAR + return data async def _async_update_data(self) -> dict[str, Any]: @@ -241,6 +260,23 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): await self.hass.async_add_executor_job(self._pause_charger, pause) await self.async_request_refresh() + @_require_authentication + def _set_eco_smart(self, option: str) -> None: + """Set wallbox solar charging mode.""" + + if option == EcoSmartMode.ECO_MODE: + self._wallbox.enableEcoSmart(self._station, 0) + elif option == EcoSmartMode.FULL_SOLAR: + self._wallbox.enableEcoSmart(self._station, 1) + else: + self._wallbox.disableEcoSmart(self._station) + + async def async_set_eco_smart(self, option: str) -> None: + """Set wallbox solar charging mode.""" + + await self.hass.async_add_executor_job(self._set_eco_smart, option) + await self.async_request_refresh() + class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/wallbox/icons.json b/homeassistant/components/wallbox/icons.json index 359e05cb441..d4495939d6d 100644 --- a/homeassistant/components/wallbox/icons.json +++ b/homeassistant/components/wallbox/icons.json @@ -1,5 +1,10 @@ { "entity": { + "select": { + "ecosmart": { + "default": "mdi:solar-power" + } + }, "sensor": { "charging_speed": { "default": "mdi:speedometer" diff --git a/homeassistant/components/wallbox/select.py b/homeassistant/components/wallbox/select.py new file mode 100644 index 00000000000..7ad7a135bc8 --- /dev/null +++ b/homeassistant/components/wallbox/select.py @@ -0,0 +1,105 @@ +"""Home Assistant component for accessing the Wallbox Portal API. The switch component creates a switch entity.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from requests import HTTPError + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import ( + CHARGER_DATA_KEY, + CHARGER_ECO_SMART_KEY, + CHARGER_FEATURES_KEY, + CHARGER_PLAN_KEY, + CHARGER_POWER_BOOST_KEY, + CHARGER_SERIAL_NUMBER_KEY, + DOMAIN, + EcoSmartMode, +) +from .coordinator import WallboxCoordinator +from .entity import WallboxEntity + + +@dataclass(frozen=True, kw_only=True) +class WallboxSelectEntityDescription(SelectEntityDescription): + """Describes Wallbox select entity.""" + + current_option_fn: Callable[[WallboxCoordinator], str | None] + select_option_fn: Callable[[WallboxCoordinator, str], Awaitable[None]] + supported_fn: Callable[[WallboxCoordinator], bool] + + +SELECT_TYPES: dict[str, WallboxSelectEntityDescription] = { + CHARGER_ECO_SMART_KEY: WallboxSelectEntityDescription( + key=CHARGER_ECO_SMART_KEY, + translation_key=CHARGER_ECO_SMART_KEY, + options=[ + EcoSmartMode.OFF, + EcoSmartMode.ECO_MODE, + EcoSmartMode.FULL_SOLAR, + ], + select_option_fn=lambda coordinator, mode: coordinator.async_set_eco_smart( + mode + ), + current_option_fn=lambda coordinator: coordinator.data[CHARGER_ECO_SMART_KEY], + supported_fn=lambda coordinator: coordinator.data[CHARGER_DATA_KEY][ + CHARGER_PLAN_KEY + ][CHARGER_FEATURES_KEY].count(CHARGER_POWER_BOOST_KEY), + ) +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Create wallbox select entities in HASS.""" + coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + WallboxSelect(coordinator, description) + for ent in coordinator.data + if ( + (description := SELECT_TYPES.get(ent)) + and description.supported_fn(coordinator) + ) + ) + + +class WallboxSelect(WallboxEntity, SelectEntity): + """Representation of the Wallbox portal.""" + + entity_description: WallboxSelectEntityDescription + + def __init__( + self, + coordinator: WallboxCoordinator, + description: WallboxSelectEntityDescription, + ) -> None: + """Initialize a Wallbox select entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{description.key}-{coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY]}" + + @property + def current_option(self) -> str | None: + """Return an option.""" + return self.entity_description.current_option_fn(self.coordinator) + + async def async_select_option(self, option: str) -> None: + """Handle the selection of an option.""" + try: + await self.entity_description.select_option_fn(self.coordinator, option) + except (ConnectionError, HTTPError) as e: + raise HomeAssistantError( + translation_key="api_failed", translation_domain=DOMAIN + ) from e + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/wallbox/strings.json b/homeassistant/components/wallbox/strings.json index f4378b328d8..7f401981286 100644 --- a/homeassistant/components/wallbox/strings.json +++ b/homeassistant/components/wallbox/strings.json @@ -91,6 +91,21 @@ "pause_resume": { "name": "Pause/resume" } + }, + "select": { + "ecosmart": { + "name": "Solar charging", + "state": { + "off": "[%key:common::state::off%]", + "eco_mode": "Eco mode", + "full_solar": "Full solar" + } + } + } + }, + "exceptions": { + "api_failed": { + "message": "Error communicating with Wallbox API" } } } diff --git a/tests/components/wallbox/__init__.py b/tests/components/wallbox/__init__.py index 9ec10dc72aa..d347777f7e8 100644 --- a/tests/components/wallbox/__init__.py +++ b/tests/components/wallbox/__init__.py @@ -2,6 +2,7 @@ from http import HTTPStatus +import requests import requests_mock from homeassistant.components.wallbox.const import ( @@ -12,6 +13,9 @@ from homeassistant.components.wallbox.const import ( CHARGER_CURRENCY_KEY, CHARGER_CURRENT_VERSION_KEY, CHARGER_DATA_KEY, + CHARGER_ECO_SMART_KEY, + CHARGER_ECO_SMART_MODE_KEY, + CHARGER_ECO_SMART_STATUS_KEY, CHARGER_ENERGY_PRICE_KEY, CHARGER_FEATURES_KEY, CHARGER_LOCKED_UNLOCKED_KEY, @@ -50,6 +54,10 @@ test_response = { CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, CHARGER_MAX_ICP_CURRENT_KEY: 20, CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]}, + CHARGER_ECO_SMART_KEY: { + CHARGER_ECO_SMART_STATUS_KEY: False, + CHARGER_ECO_SMART_MODE_KEY: 0, + }, }, } @@ -71,9 +79,89 @@ test_response_bidir = { CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, CHARGER_MAX_ICP_CURRENT_KEY: 20, CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]}, + CHARGER_ECO_SMART_KEY: { + CHARGER_ECO_SMART_STATUS_KEY: False, + CHARGER_ECO_SMART_MODE_KEY: 0, + }, }, } +test_response_eco_mode = { + CHARGER_CHARGING_POWER_KEY: 0, + CHARGER_STATUS_ID_KEY: 193, + CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, + CHARGER_CHARGING_SPEED_KEY: 0, + CHARGER_ADDED_RANGE_KEY: 150, + CHARGER_ADDED_ENERGY_KEY: 44.697, + CHARGER_NAME_KEY: "WallboxName", + CHARGER_DATA_KEY: { + CHARGER_MAX_CHARGING_CURRENT_KEY: 24, + CHARGER_ENERGY_PRICE_KEY: 0.4, + CHARGER_LOCKED_UNLOCKED_KEY: False, + CHARGER_SERIAL_NUMBER_KEY: "20000", + CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E", + CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, + CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, + CHARGER_MAX_ICP_CURRENT_KEY: 20, + CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]}, + CHARGER_ECO_SMART_KEY: { + CHARGER_ECO_SMART_STATUS_KEY: True, + CHARGER_ECO_SMART_MODE_KEY: 0, + }, + }, +} + + +test_response_full_solar = { + CHARGER_CHARGING_POWER_KEY: 0, + CHARGER_STATUS_ID_KEY: 193, + CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, + CHARGER_CHARGING_SPEED_KEY: 0, + CHARGER_ADDED_RANGE_KEY: 150, + CHARGER_ADDED_ENERGY_KEY: 44.697, + CHARGER_NAME_KEY: "WallboxName", + CHARGER_DATA_KEY: { + CHARGER_MAX_CHARGING_CURRENT_KEY: 24, + CHARGER_ENERGY_PRICE_KEY: 0.4, + CHARGER_LOCKED_UNLOCKED_KEY: False, + CHARGER_SERIAL_NUMBER_KEY: "20000", + CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E", + CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, + CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, + CHARGER_MAX_ICP_CURRENT_KEY: 20, + CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]}, + CHARGER_ECO_SMART_KEY: { + CHARGER_ECO_SMART_STATUS_KEY: True, + CHARGER_ECO_SMART_MODE_KEY: 1, + }, + }, +} + +test_response_no_power_boost = { + CHARGER_CHARGING_POWER_KEY: 0, + CHARGER_STATUS_ID_KEY: 193, + CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, + CHARGER_CHARGING_SPEED_KEY: 0, + CHARGER_ADDED_RANGE_KEY: 150, + CHARGER_ADDED_ENERGY_KEY: 44.697, + CHARGER_NAME_KEY: "WallboxName", + CHARGER_DATA_KEY: { + CHARGER_MAX_CHARGING_CURRENT_KEY: 24, + CHARGER_ENERGY_PRICE_KEY: 0.4, + CHARGER_LOCKED_UNLOCKED_KEY: False, + CHARGER_SERIAL_NUMBER_KEY: "20000", + CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E", + CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, + CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, + CHARGER_MAX_ICP_CURRENT_KEY: 20, + CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: []}, + }, +} + + +http_404_error = requests.exceptions.HTTPError() +http_404_error.response = requests.Response() +http_404_error.response.status_code = HTTPStatus.NOT_FOUND authorisation_response = { "data": { @@ -128,6 +216,31 @@ async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None await hass.async_block_till_done() +async def setup_integration_select( + hass: HomeAssistant, entry: MockConfigEntry, response +) -> None: + """Test wallbox sensor class setup.""" + with requests_mock.Mocker() as mock_request: + mock_request.get( + "https://user-api.wall-box.com/users/signin", + json=authorisation_response, + status_code=HTTPStatus.OK, + ) + mock_request.get( + "https://api.wall-box.com/chargers/status/12345", + json=response, + status_code=HTTPStatus.OK, + ) + mock_request.put( + "https://api.wall-box.com/v2/charger/12345", + json={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}, + status_code=HTTPStatus.OK, + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + async def setup_integration_bidir(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test wallbox sensor class setup.""" with requests_mock.Mocker() as mock_request: diff --git a/tests/components/wallbox/const.py b/tests/components/wallbox/const.py index a86ae9fc3b9..82c9e5169d5 100644 --- a/tests/components/wallbox/const.py +++ b/tests/components/wallbox/const.py @@ -15,3 +15,4 @@ MOCK_SENSOR_CHARGING_SPEED_ID = "sensor.wallbox_wallboxname_charging_speed" MOCK_SENSOR_CHARGING_POWER_ID = "sensor.wallbox_wallboxname_charging_power" MOCK_SENSOR_MAX_AVAILABLE_POWER = "sensor.wallbox_wallboxname_max_available_power" MOCK_SWITCH_ENTITY_ID = "switch.wallbox_wallboxname_pause_resume" +MOCK_SELECT_ENTITY_ID = "select.wallbox_wallboxname_solar_charging" diff --git a/tests/components/wallbox/test_select.py b/tests/components/wallbox/test_select.py new file mode 100644 index 00000000000..516b1e87c27 --- /dev/null +++ b/tests/components/wallbox/test_select.py @@ -0,0 +1,122 @@ +"""Test Wallbox Select component.""" + +from unittest.mock import Mock, patch + +import pytest + +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.components.wallbox.const import CHARGER_STATUS_ID_KEY, EcoSmartMode +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant, HomeAssistantError + +from . import ( + authorisation_response, + http_404_error, + setup_integration_select, + test_response, + test_response_eco_mode, + test_response_full_solar, + test_response_no_power_boost, +) +from .const import MOCK_SELECT_ENTITY_ID + +from tests.common import MockConfigEntry + +TEST_OPTIONS = [ + (EcoSmartMode.OFF, test_response), + (EcoSmartMode.ECO_MODE, test_response_eco_mode), + (EcoSmartMode.FULL_SOLAR, test_response_full_solar), +] + + +@pytest.fixture +def mock_authenticate(): + """Fixture to patch Wallbox methods.""" + with patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ): + yield + + +@pytest.mark.parametrize(("mode", "response"), TEST_OPTIONS) +async def test_wallbox_select_solar_charging_class( + hass: HomeAssistant, entry: MockConfigEntry, mode, response, mock_authenticate +) -> None: + """Test wallbox select class.""" + + with ( + patch( + "homeassistant.components.wallbox.Wallbox.enableEcoSmart", + new=Mock(return_value={CHARGER_STATUS_ID_KEY: 193}), + ), + patch( + "homeassistant.components.wallbox.Wallbox.disableEcoSmart", + new=Mock(return_value={CHARGER_STATUS_ID_KEY: 193}), + ), + ): + await setup_integration_select(hass, entry, response) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: MOCK_SELECT_ENTITY_ID, + ATTR_OPTION: mode, + }, + blocking=True, + ) + + state = hass.states.get(MOCK_SELECT_ENTITY_ID) + assert state.state == mode + + +async def test_wallbox_select_no_power_boost_class( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test wallbox select class.""" + + await setup_integration_select(hass, entry, test_response_no_power_boost) + + state = hass.states.get(MOCK_SELECT_ENTITY_ID) + assert state is None + + +@pytest.mark.parametrize(("mode", "response"), TEST_OPTIONS) +@pytest.mark.parametrize("error", [http_404_error, ConnectionError]) +async def test_wallbox_select_class_error( + hass: HomeAssistant, + entry: MockConfigEntry, + mode, + response, + error, + mock_authenticate, +) -> None: + """Test wallbox select class connection error.""" + + await setup_integration_select(hass, entry, response) + + with ( + patch( + "homeassistant.components.wallbox.Wallbox.disableEcoSmart", + new=Mock(side_effect=error), + ), + patch( + "homeassistant.components.wallbox.Wallbox.enableEcoSmart", + new=Mock(side_effect=error), + ), + pytest.raises(HomeAssistantError, match="Error communicating with Wallbox API"), + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: MOCK_SELECT_ENTITY_ID, + ATTR_OPTION: mode, + }, + blocking=True, + )