Files
core/homeassistant/components/wallbox/coordinator.py
Hessel 25e2c9ee80 Cache token info in Wallbox (#154147)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-20 11:12:11 +01:00

454 lines
18 KiB
Python

"""DataUpdateCoordinator for the wallbox integration."""
from __future__ import annotations
from collections.abc import Callable
from datetime import datetime, timedelta
from http import HTTPStatus
import logging
from typing import Any, Concatenate
import requests
from wallbox import Wallbox
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
CHARGER_CURRENCY_KEY,
CHARGER_DATA_KEY,
CHARGER_DATA_POST_L1_KEY,
CHARGER_DATA_POST_L2_KEY,
CHARGER_ECO_SMART_KEY,
CHARGER_ECO_SMART_MODE_KEY,
CHARGER_ECO_SMART_STATUS_KEY,
CHARGER_ENERGY_PRICE_KEY,
CHARGER_FEATURES_KEY,
CHARGER_JWT_REFRESH_TOKEN,
CHARGER_JWT_REFRESH_TTL,
CHARGER_JWT_TOKEN,
CHARGER_JWT_TTL,
CHARGER_LOCKED_UNLOCKED_KEY,
CHARGER_MAX_CHARGING_CURRENT_KEY,
CHARGER_MAX_CHARGING_CURRENT_POST_KEY,
CHARGER_MAX_ICP_CURRENT_KEY,
CHARGER_PLAN_KEY,
CHARGER_POWER_BOOST_KEY,
CHARGER_STATUS_DESCRIPTION_KEY,
CHARGER_STATUS_ID_KEY,
CODE_KEY,
CONF_STATION,
DOMAIN,
UPDATE_INTERVAL,
ChargerStatus,
EcoSmartMode,
)
_LOGGER = logging.getLogger(__name__)
# Translation of StatusId based on Wallbox portal code:
# https://my.wallbox.com/src/utilities/charger/chargerStatuses.js
CHARGER_STATUS: dict[int, ChargerStatus] = {
0: ChargerStatus.DISCONNECTED,
14: ChargerStatus.ERROR,
15: ChargerStatus.ERROR,
161: ChargerStatus.READY,
162: ChargerStatus.READY,
163: ChargerStatus.DISCONNECTED,
164: ChargerStatus.WAITING,
165: ChargerStatus.LOCKED,
166: ChargerStatus.UPDATING,
177: ChargerStatus.SCHEDULED,
178: ChargerStatus.PAUSED,
179: ChargerStatus.SCHEDULED,
180: ChargerStatus.WAITING_FOR_CAR,
181: ChargerStatus.WAITING_FOR_CAR,
182: ChargerStatus.PAUSED,
183: ChargerStatus.WAITING_IN_QUEUE_POWER_SHARING,
184: ChargerStatus.WAITING_IN_QUEUE_POWER_SHARING,
185: ChargerStatus.WAITING_IN_QUEUE_POWER_BOOST,
186: ChargerStatus.WAITING_IN_QUEUE_POWER_BOOST,
187: ChargerStatus.WAITING_MID_FAILED,
188: ChargerStatus.WAITING_MID_SAFETY,
189: ChargerStatus.WAITING_IN_QUEUE_ECO_SMART,
193: ChargerStatus.CHARGING,
194: ChargerStatus.CHARGING,
195: ChargerStatus.CHARGING,
196: ChargerStatus.DISCHARGING,
209: ChargerStatus.LOCKED,
210: ChargerStatus.LOCKED_CAR_CONNECTED,
}
type WallboxConfigEntry = ConfigEntry[WallboxCoordinator]
def _require_authentication[_WallboxCoordinatorT: WallboxCoordinator, **_P](
func: Callable[Concatenate[_WallboxCoordinatorT, _P], Any],
) -> Callable[Concatenate[_WallboxCoordinatorT, _P], Any]:
"""Authenticate with decorator using Wallbox API."""
async def require_authentication(
self: _WallboxCoordinatorT, *args: _P.args, **kwargs: _P.kwargs
) -> Any:
"""Authenticate using Wallbox API."""
await self.async_authenticate()
return await func(self, *args, **kwargs)
return require_authentication
def check_token_validity(jwt_token_ttl: int, jwt_token_drift: int) -> bool:
"""Check if the jwtToken is still valid in order to reuse if possible."""
return round((jwt_token_ttl / 1000) - jwt_token_drift, 0) > datetime.timestamp(
datetime.now()
)
def _validate(wallbox: Wallbox) -> None:
"""Authenticate using Wallbox API to check if the used credentials are valid."""
try:
wallbox.authenticate()
except requests.exceptions.HTTPError as wallbox_connection_error:
if wallbox_connection_error.response.status_code == 403:
raise InvalidAuth(
translation_domain=DOMAIN, translation_key="invalid_auth"
) from wallbox_connection_error
raise ConnectionError from wallbox_connection_error
async def async_validate_input(hass: HomeAssistant, wallbox: Wallbox) -> None:
"""Get new sensor data for Wallbox component."""
await hass.async_add_executor_job(_validate, wallbox)
class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Wallbox Coordinator class."""
config_entry: WallboxConfigEntry
def __init__(
self, hass: HomeAssistant, config_entry: WallboxConfigEntry, wallbox: Wallbox
) -> None:
"""Initialize."""
self._station = config_entry.data[CONF_STATION]
self._wallbox = wallbox
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=timedelta(seconds=UPDATE_INTERVAL),
)
def _authenticate(self) -> dict[str, str]:
"""Authenticate using Wallbox API. First check token validity."""
data = dict(self.config_entry.data)
if not check_token_validity(
jwt_token_ttl=data.get(CHARGER_JWT_TTL, 0),
jwt_token_drift=UPDATE_INTERVAL,
):
try:
self._wallbox.authenticate()
except requests.exceptions.HTTPError as wallbox_connection_error:
if (
wallbox_connection_error.response.status_code
== HTTPStatus.FORBIDDEN
):
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="invalid_auth"
) from wallbox_connection_error
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="api_failed"
) from wallbox_connection_error
else:
data[CHARGER_JWT_TOKEN] = self._wallbox.jwtToken
data[CHARGER_JWT_REFRESH_TOKEN] = self._wallbox.jwtRefreshToken
data[CHARGER_JWT_TTL] = self._wallbox.jwtTokenTtl
data[CHARGER_JWT_REFRESH_TTL] = self._wallbox.jwtRefreshTokenTtl
return data
async def async_authenticate(self) -> None:
"""Authenticate using Wallbox API."""
data = await self.hass.async_add_executor_job(self._authenticate)
self.hass.config_entries.async_update_entry(self.config_entry, data=data)
def _get_data(self) -> dict[str, Any]:
"""Get new sensor data for Wallbox component."""
try:
data: dict[str, Any] = self._wallbox.getChargerStatus(self._station)
data[CHARGER_MAX_CHARGING_CURRENT_KEY] = data[CHARGER_DATA_KEY][
CHARGER_MAX_CHARGING_CURRENT_KEY
]
data[CHARGER_LOCKED_UNLOCKED_KEY] = data[CHARGER_DATA_KEY][
CHARGER_LOCKED_UNLOCKED_KEY
]
data[CHARGER_ENERGY_PRICE_KEY] = data[CHARGER_DATA_KEY][
CHARGER_ENERGY_PRICE_KEY
]
# Only show max_icp_current if power_boost is available in the wallbox unit:
if (
data[CHARGER_DATA_KEY].get(CHARGER_MAX_ICP_CURRENT_KEY, 0) > 0
and CHARGER_POWER_BOOST_KEY
in data[CHARGER_DATA_KEY][CHARGER_PLAN_KEY][CHARGER_FEATURES_KEY]
):
data[CHARGER_MAX_ICP_CURRENT_KEY] = data[CHARGER_DATA_KEY][
CHARGER_MAX_ICP_CURRENT_KEY
]
data[CHARGER_CURRENCY_KEY] = (
f"{data[CHARGER_DATA_KEY][CHARGER_CURRENCY_KEY][CODE_KEY]}/kWh"
)
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]
.get(CHARGER_ECO_SMART_KEY, {})
.get(CHARGER_ECO_SMART_STATUS_KEY)
)
eco_smart_mode = (
data[CHARGER_DATA_KEY]
.get(CHARGER_ECO_SMART_KEY, {})
.get(CHARGER_ECO_SMART_MODE_KEY)
)
if eco_smart_mode is None:
data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.DISABLED
elif 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 # noqa: TRY300
except requests.exceptions.HTTPError as wallbox_connection_error:
if wallbox_connection_error.response.status_code == 429:
raise UpdateFailed(
translation_domain=DOMAIN, translation_key="too_many_requests"
) from wallbox_connection_error
raise UpdateFailed(
translation_domain=DOMAIN, translation_key="api_failed"
) from wallbox_connection_error
@_require_authentication
async def _async_update_data(self) -> dict[str, Any]:
"""Get new sensor data for Wallbox component. Set update interval to be UPDATE_INTERVAL * #wallbox chargers configured, this is necessary due to rate limitations."""
self.update_interval = timedelta(
seconds=UPDATE_INTERVAL
* max(len(self.hass.config_entries.async_loaded_entries(DOMAIN)), 1)
)
return await self.hass.async_add_executor_job(self._get_data)
def _set_charging_current(
self, charging_current: float
) -> dict[str, dict[str, dict[str, Any]]]:
"""Set maximum charging current for Wallbox."""
try:
result = self._wallbox.setMaxChargingCurrent(
self._station, charging_current
)
data = self.data
data[CHARGER_MAX_CHARGING_CURRENT_KEY] = result[CHARGER_DATA_POST_L1_KEY][
CHARGER_DATA_POST_L2_KEY
][CHARGER_MAX_CHARGING_CURRENT_POST_KEY]
return data # noqa: TRY300
except requests.exceptions.HTTPError as wallbox_connection_error:
if wallbox_connection_error.response.status_code == 403:
raise InsufficientRights(
translation_domain=DOMAIN,
translation_key="insufficient_rights",
hass=self.hass,
) from wallbox_connection_error
if wallbox_connection_error.response.status_code == 429:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="too_many_requests"
) from wallbox_connection_error
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="api_failed"
) from wallbox_connection_error
@_require_authentication
async def async_set_charging_current(self, charging_current: float) -> None:
"""Set maximum charging current for Wallbox."""
data = await self.hass.async_add_executor_job(
self._set_charging_current, charging_current
)
self.async_set_updated_data(data)
def _set_icp_current(self, icp_current: float) -> dict[str, Any]:
"""Set maximum icp current for Wallbox."""
try:
result = self._wallbox.setIcpMaxCurrent(self._station, icp_current)
data = self.data
data[CHARGER_MAX_ICP_CURRENT_KEY] = result[CHARGER_MAX_ICP_CURRENT_KEY]
return data # noqa: TRY300
except requests.exceptions.HTTPError as wallbox_connection_error:
if wallbox_connection_error.response.status_code == 403:
raise InsufficientRights(
translation_domain=DOMAIN,
translation_key="insufficient_rights",
hass=self.hass,
) from wallbox_connection_error
if wallbox_connection_error.response.status_code == 429:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="too_many_requests"
) from wallbox_connection_error
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="api_failed"
) from wallbox_connection_error
@_require_authentication
async def async_set_icp_current(self, icp_current: float) -> None:
"""Set maximum icp current for Wallbox."""
data = await self.hass.async_add_executor_job(
self._set_icp_current, icp_current
)
self.async_set_updated_data(data)
def _set_energy_cost(self, energy_cost: float) -> dict[str, Any]:
"""Set energy cost for Wallbox."""
try:
result = self._wallbox.setEnergyCost(self._station, energy_cost)
data = self.data
data[CHARGER_ENERGY_PRICE_KEY] = result[CHARGER_ENERGY_PRICE_KEY]
return data # noqa: TRY300
except requests.exceptions.HTTPError as wallbox_connection_error:
if wallbox_connection_error.response.status_code == 429:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="too_many_requests"
) from wallbox_connection_error
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="api_failed"
) from wallbox_connection_error
@_require_authentication
async def async_set_energy_cost(self, energy_cost: float) -> None:
"""Set energy cost for Wallbox."""
data = await self.hass.async_add_executor_job(
self._set_energy_cost, energy_cost
)
self.async_set_updated_data(data)
def _set_lock_unlock(self, lock: bool) -> dict[str, dict[str, dict[str, Any]]]:
"""Set wallbox to locked or unlocked."""
try:
if lock:
result = self._wallbox.lockCharger(self._station)
else:
result = self._wallbox.unlockCharger(self._station)
data = self.data
data[CHARGER_LOCKED_UNLOCKED_KEY] = result[CHARGER_DATA_POST_L1_KEY][
CHARGER_DATA_POST_L2_KEY
][CHARGER_LOCKED_UNLOCKED_KEY]
return data # noqa: TRY300
except requests.exceptions.HTTPError as wallbox_connection_error:
if wallbox_connection_error.response.status_code == 403:
raise InsufficientRights(
translation_domain=DOMAIN,
translation_key="insufficient_rights",
hass=self.hass,
) from wallbox_connection_error
if wallbox_connection_error.response.status_code == 429:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="too_many_requests"
) from wallbox_connection_error
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="api_failed"
) from wallbox_connection_error
@_require_authentication
async def async_set_lock_unlock(self, lock: bool) -> None:
"""Set wallbox to locked or unlocked."""
data = await self.hass.async_add_executor_job(self._set_lock_unlock, lock)
self.async_set_updated_data(data)
def _pause_charger(self, pause: bool) -> None:
"""Set wallbox to pause or resume."""
try:
if pause:
self._wallbox.pauseChargingSession(self._station)
else:
self._wallbox.resumeChargingSession(self._station)
except requests.exceptions.HTTPError as wallbox_connection_error:
if wallbox_connection_error.response.status_code == 429:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="too_many_requests"
) from wallbox_connection_error
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="api_failed"
) from wallbox_connection_error
@_require_authentication
async def async_pause_charger(self, pause: bool) -> None:
"""Set wallbox to pause or resume."""
await self.hass.async_add_executor_job(self._pause_charger, pause)
await self.async_request_refresh()
def _set_eco_smart(self, option: str) -> None:
"""Set wallbox solar charging mode."""
try:
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)
except requests.exceptions.HTTPError as wallbox_connection_error:
if wallbox_connection_error.response.status_code == 429:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="too_many_requests"
) from wallbox_connection_error
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="api_failed"
) from wallbox_connection_error
@_require_authentication
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."""
class InsufficientRights(HomeAssistantError):
"""Error to indicate there are insufficient right for the user."""
def __init__(
self,
*args: object,
translation_domain: str | None = None,
translation_key: str | None = None,
translation_placeholders: dict[str, str] | None = None,
hass: HomeAssistant,
) -> None:
"""Initialize exception."""
super().__init__(
self, *args, translation_domain, translation_key, translation_placeholders
)
self.hass = hass
self._create_insufficient_rights_issue()
def _create_insufficient_rights_issue(self) -> None:
"""Creates an issue for insufficient rights."""
ir.create_issue(
self.hass,
DOMAIN,
"insufficient_rights",
is_fixable=False,
severity=ir.IssueSeverity.ERROR,
learn_more_url="https://www.home-assistant.io/integrations/wallbox/#troubleshooting",
translation_key="insufficient_rights",
)