Add energy price number entities to Wallbox (#101840)

This commit is contained in:
Hessel 2023-10-21 17:53:32 +02:00 committed by GitHub
parent e5b5858915
commit bd0df2f18f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 227 additions and 17 deletions

View File

@ -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:

View File

@ -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)

View File

@ -35,6 +35,9 @@
"number": {
"maximum_charging_current": {
"name": "Maximum charging current"
},
"energy_price": {
"name": "Energy price"
}
},
"sensor": {

View File

@ -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:

View File

@ -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"

View File

@ -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: