diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index fe8dd2469c3..b9248d8ce5b 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -150,6 +150,21 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): ) await self.async_request_refresh() + def _set_energy_cost(self, energy_cost: float) -> None: + """Set energy cost for Wallbox.""" + try: + self._authenticate() + self._wallbox.setEnergyCost(self._station, energy_cost) + except requests.exceptions.HTTPError as wallbox_connection_error: + if wallbox_connection_error.response.status_code == 403: + raise InvalidAuth from wallbox_connection_error + raise ConnectionError from wallbox_connection_error + + async def async_set_energy_cost(self, energy_cost: float) -> None: + """Set energy cost for Wallbox.""" + await self.hass.async_add_executor_job(self._set_energy_cost, energy_cost) + await self.async_request_refresh() + def _set_lock_unlock(self, lock: bool) -> None: """Set wallbox to locked or unlocked.""" try: diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py index 13938626336..9694e13103c 100644 --- a/homeassistant/components/wallbox/number.py +++ b/homeassistant/components/wallbox/number.py @@ -4,6 +4,7 @@ The number component allows control of charging current. """ from __future__ import annotations +from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import cast @@ -16,6 +17,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( BIDIRECTIONAL_MODEL_PREFIXES, CHARGER_DATA_KEY, + CHARGER_ENERGY_PRICE_KEY, CHARGER_MAX_AVAILABLE_POWER_KEY, CHARGER_MAX_CHARGING_CURRENT_KEY, CHARGER_PART_NUMBER_KEY, @@ -26,8 +28,29 @@ from .coordinator import InvalidAuth, WallboxCoordinator from .entity import WallboxEntity +def min_charging_current_value(coordinator: WallboxCoordinator) -> float: + """Return the minimum available value for charging current.""" + if ( + coordinator.data[CHARGER_DATA_KEY][CHARGER_PART_NUMBER_KEY][0:2] + in BIDIRECTIONAL_MODEL_PREFIXES + ): + return cast(float, (coordinator.data[CHARGER_MAX_AVAILABLE_POWER_KEY] * -1)) + return 0 + + @dataclass -class WallboxNumberEntityDescription(NumberEntityDescription): +class WallboxNumberEntityDescriptionMixin: + """Load entities from different handlers.""" + + max_value_fn: Callable[[WallboxCoordinator], float] + min_value_fn: Callable[[WallboxCoordinator], float] + set_value_fn: Callable[[WallboxCoordinator], Callable[[float], Awaitable[None]]] + + +@dataclass +class WallboxNumberEntityDescription( + NumberEntityDescription, WallboxNumberEntityDescriptionMixin +): """Describes Wallbox number entity.""" @@ -35,6 +58,20 @@ NUMBER_TYPES: dict[str, WallboxNumberEntityDescription] = { CHARGER_MAX_CHARGING_CURRENT_KEY: WallboxNumberEntityDescription( key=CHARGER_MAX_CHARGING_CURRENT_KEY, translation_key="maximum_charging_current", + max_value_fn=lambda coordinator: cast( + float, coordinator.data[CHARGER_MAX_AVAILABLE_POWER_KEY] + ), + min_value_fn=min_charging_current_value, + set_value_fn=lambda coordinator: coordinator.async_set_charging_current, + native_step=1, + ), + CHARGER_ENERGY_PRICE_KEY: WallboxNumberEntityDescription( + key=CHARGER_ENERGY_PRICE_KEY, + translation_key="energy_price", + max_value_fn=lambda _: 5, + min_value_fn=lambda _: -5, + set_value_fn=lambda coordinator: coordinator.async_set_energy_cost, + native_step=0.01, ), } @@ -44,7 +81,7 @@ async def async_setup_entry( ) -> None: """Create wallbox number entities in HASS.""" coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] - # Check if the user is authorized to change current, if so, add number component: + # Check if the user has sufficient rights to change values, if so, add number component: try: await coordinator.async_set_charging_current( coordinator.data[CHARGER_MAX_CHARGING_CURRENT_KEY] @@ -79,28 +116,22 @@ class WallboxNumber(WallboxEntity, NumberEntity): self.entity_description = description self._coordinator = coordinator self._attr_unique_id = f"{description.key}-{coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY]}" - self._is_bidirectional = ( - coordinator.data[CHARGER_DATA_KEY][CHARGER_PART_NUMBER_KEY][0:2] - in BIDIRECTIONAL_MODEL_PREFIXES - ) @property def native_max_value(self) -> float: - """Return the maximum available current.""" - return cast(float, self._coordinator.data[CHARGER_MAX_AVAILABLE_POWER_KEY]) + """Return the maximum available value.""" + return self.entity_description.max_value_fn(self.coordinator) @property def native_min_value(self) -> float: - """Return the minimum available current based on charger type - some chargers can discharge.""" - return (self.max_value * -1) if self._is_bidirectional else 6 + """Return the minimum available value.""" + return self.entity_description.min_value_fn(self.coordinator) @property def native_value(self) -> float | None: """Return the value of the entity.""" - return cast( - float | None, self._coordinator.data[CHARGER_MAX_CHARGING_CURRENT_KEY] - ) + return cast(float | None, self._coordinator.data[self.entity_description.key]) async def async_set_native_value(self, value: float) -> None: """Set the value of the entity.""" - await self._coordinator.async_set_charging_current(value) + await self.entity_description.set_value_fn(self.coordinator)(value) diff --git a/homeassistant/components/wallbox/strings.json b/homeassistant/components/wallbox/strings.json index 69db4bb97e3..dd96cebf605 100644 --- a/homeassistant/components/wallbox/strings.json +++ b/homeassistant/components/wallbox/strings.json @@ -35,6 +35,9 @@ "number": { "maximum_charging_current": { "name": "Maximum charging current" + }, + "energy_price": { + "name": "Energy price" } }, "sensor": { diff --git a/tests/components/wallbox/__init__.py b/tests/components/wallbox/__init__.py index b995a066c51..d9bf9cfceaf 100644 --- a/tests/components/wallbox/__init__.py +++ b/tests/components/wallbox/__init__.py @@ -34,7 +34,7 @@ test_response = json.loads( { CHARGER_CHARGING_POWER_KEY: 0, CHARGER_STATUS_ID_KEY: 193, - CHARGER_MAX_AVAILABLE_POWER_KEY: 25.2, + CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, CHARGER_CHARGING_SPEED_KEY: 0, CHARGER_ADDED_RANGE_KEY: 150, CHARGER_ADDED_ENERGY_KEY: 44.697, @@ -52,6 +52,26 @@ test_response = json.loads( ) ) +test_response_bidir = { + 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: "QSP1-0-2-4-9-002-E", + CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, + CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, + }, +} + + authorisation_response = json.loads( json.dumps( { @@ -109,6 +129,29 @@ async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None 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: + 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=test_response_bidir, + status_code=HTTPStatus.OK, + ) + mock_request.put( + "https://api.wall-box.com/v2/charger/12345", + json=json.loads(json.dumps({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_connection_error( hass: HomeAssistant, entry: MockConfigEntry ) -> None: diff --git a/tests/components/wallbox/const.py b/tests/components/wallbox/const.py index 477fb10d292..4480b1ea7a4 100644 --- a/tests/components/wallbox/const.py +++ b/tests/components/wallbox/const.py @@ -6,6 +6,7 @@ ERROR = "error" STATUS = "status" MOCK_NUMBER_ENTITY_ID = "number.wallbox_wallboxname_maximum_charging_current" +MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID = "number.wallbox_wallboxname_energy_price" MOCK_LOCK_ENTITY_ID = "lock.wallbox_wallboxname_lock" MOCK_SENSOR_CHARGING_SPEED_ID = "sensor.wallbox_wallboxname_charging_speed" MOCK_SENSOR_CHARGING_POWER_ID = "sensor.wallbox_wallboxname_charging_power" diff --git a/tests/components/wallbox/test_number.py b/tests/components/wallbox/test_number.py index 9d1663bf002..41ebedc91da 100644 --- a/tests/components/wallbox/test_number.py +++ b/tests/components/wallbox/test_number.py @@ -5,16 +5,21 @@ import pytest import requests_mock from homeassistant.components.input_number import ATTR_VALUE, SERVICE_SET_VALUE -from homeassistant.components.wallbox.const import CHARGER_MAX_CHARGING_CURRENT_KEY +from homeassistant.components.wallbox.const import ( + CHARGER_ENERGY_PRICE_KEY, + CHARGER_MAX_CHARGING_CURRENT_KEY, +) +from homeassistant.components.wallbox.coordinator import InvalidAuth from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from . import ( authorisation_response, setup_integration, + setup_integration_bidir, setup_integration_platform_not_ready, ) -from .const import MOCK_NUMBER_ENTITY_ID +from .const import MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, MOCK_NUMBER_ENTITY_ID from tests.common import MockConfigEntry @@ -37,6 +42,9 @@ async def test_wallbox_number_class( json=json.loads(json.dumps({CHARGER_MAX_CHARGING_CURRENT_KEY: 20})), status_code=200, ) + state = hass.states.get(MOCK_NUMBER_ENTITY_ID) + assert state.attributes["min"] == 0 + assert state.attributes["max"] == 25 await hass.services.async_call( "number", @@ -50,6 +58,51 @@ async def test_wallbox_number_class( await hass.config_entries.async_unload(entry.entry_id) +async def test_wallbox_number_class_bidir( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test wallbox sensor class.""" + + await setup_integration_bidir(hass, entry) + + state = hass.states.get(MOCK_NUMBER_ENTITY_ID) + assert state.attributes["min"] == -25 + assert state.attributes["max"] == 25 + await hass.config_entries.async_unload(entry.entry_id) + + +async def test_wallbox_number_energy_class( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test wallbox sensor class.""" + + await setup_integration(hass, entry) + + with requests_mock.Mocker() as mock_request: + mock_request.get( + "https://user-api.wall-box.com/users/signin", + json=authorisation_response, + status_code=200, + ) + + mock_request.post( + "https://api.wall-box.com/chargers/config/12345", + json=json.loads(json.dumps({CHARGER_ENERGY_PRICE_KEY: 1.1})), + status_code=200, + ) + + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, + ATTR_VALUE: 1.1, + }, + blocking=True, + ) + await hass.config_entries.async_unload(entry.entry_id) + + async def test_wallbox_number_class_connection_error( hass: HomeAssistant, entry: MockConfigEntry ) -> None: @@ -82,6 +135,70 @@ async def test_wallbox_number_class_connection_error( await hass.config_entries.async_unload(entry.entry_id) +async def test_wallbox_number_class_energy_price_connection_error( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test wallbox sensor class.""" + + await setup_integration(hass, entry) + + with requests_mock.Mocker() as mock_request: + mock_request.get( + "https://user-api.wall-box.com/users/signin", + json=authorisation_response, + status_code=200, + ) + mock_request.post( + "https://api.wall-box.com/chargers/config/12345", + json=json.loads(json.dumps({CHARGER_ENERGY_PRICE_KEY: 1.1})), + status_code=404, + ) + + with pytest.raises(ConnectionError): + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, + ATTR_VALUE: 1.1, + }, + blocking=True, + ) + await hass.config_entries.async_unload(entry.entry_id) + + +async def test_wallbox_number_class_energy_price_auth_error( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test wallbox sensor class.""" + + await setup_integration(hass, entry) + + with requests_mock.Mocker() as mock_request: + mock_request.get( + "https://user-api.wall-box.com/users/signin", + json=authorisation_response, + status_code=200, + ) + mock_request.post( + "https://api.wall-box.com/chargers/config/12345", + json=json.loads(json.dumps({CHARGER_ENERGY_PRICE_KEY: 1.1})), + status_code=403, + ) + + with pytest.raises(InvalidAuth): + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, + ATTR_VALUE: 1.1, + }, + blocking=True, + ) + await hass.config_entries.async_unload(entry.entry_id) + + async def test_wallbox_number_class_platform_not_ready( hass: HomeAssistant, entry: MockConfigEntry ) -> None: