Skip to content

Commit ffe524d

Browse files
hesselonlinejoostlek
authored andcommitted
Cache token info in Wallbox (home-assistant#154147)
Co-authored-by: Joost Lekkerkerker <[email protected]>
1 parent 168c915 commit ffe524d

File tree

7 files changed

+158
-82
lines changed

7 files changed

+158
-82
lines changed

homeassistant/components/wallbox/__init__.py

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,15 @@
66

77
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
88
from homeassistant.core import HomeAssistant
9-
from homeassistant.exceptions import ConfigEntryAuthFailed
10-
11-
from .const import UPDATE_INTERVAL
12-
from .coordinator import (
13-
InvalidAuth,
14-
WallboxConfigEntry,
15-
WallboxCoordinator,
16-
async_validate_input,
9+
10+
from .const import (
11+
CHARGER_JWT_REFRESH_TOKEN,
12+
CHARGER_JWT_REFRESH_TTL,
13+
CHARGER_JWT_TOKEN,
14+
CHARGER_JWT_TTL,
15+
UPDATE_INTERVAL,
1716
)
17+
from .coordinator import WallboxConfigEntry, WallboxCoordinator, check_token_validity
1818

1919
PLATFORMS = [
2020
Platform.LOCK,
@@ -32,10 +32,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: WallboxConfigEntry) -> b
3232
entry.data[CONF_PASSWORD],
3333
jwtTokenDrift=UPDATE_INTERVAL,
3434
)
35-
try:
36-
await async_validate_input(hass, wallbox)
37-
except InvalidAuth as ex:
38-
raise ConfigEntryAuthFailed from ex
35+
36+
if CHARGER_JWT_TOKEN in entry.data and check_token_validity(
37+
jwt_token_ttl=entry.data.get(CHARGER_JWT_TTL, 0),
38+
jwt_token_drift=UPDATE_INTERVAL,
39+
):
40+
wallbox.jwtToken = entry.data.get(CHARGER_JWT_TOKEN)
41+
wallbox.jwtRefreshToken = entry.data.get(CHARGER_JWT_REFRESH_TOKEN)
42+
wallbox.jwtTokenTtl = entry.data.get(CHARGER_JWT_TTL)
43+
wallbox.jwtRefreshTokenTtl = entry.data.get(CHARGER_JWT_REFRESH_TTL)
44+
wallbox.headers["Authorization"] = f"Bearer {entry.data.get(CHARGER_JWT_TOKEN)}"
3945

4046
wallbox_coordinator = WallboxCoordinator(hass, entry, wallbox)
4147
await wallbox_coordinator.async_config_entry_first_refresh()

homeassistant/components/wallbox/config_flow.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,15 @@
1212
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
1313
from homeassistant.core import HomeAssistant
1414

15-
from .const import CONF_STATION, DOMAIN
15+
from .const import (
16+
CHARGER_JWT_REFRESH_TOKEN,
17+
CHARGER_JWT_REFRESH_TTL,
18+
CHARGER_JWT_TOKEN,
19+
CHARGER_JWT_TTL,
20+
CONF_STATION,
21+
DOMAIN,
22+
UPDATE_INTERVAL,
23+
)
1624
from .coordinator import InvalidAuth, async_validate_input
1725

1826
COMPONENT_DOMAIN = DOMAIN
@@ -26,17 +34,22 @@
2634
)
2735

2836

29-
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]:
37+
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
3038
"""Validate the user input allows to connect.
3139
3240
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
3341
"""
34-
wallbox = Wallbox(data["username"], data["password"])
42+
wallbox = Wallbox(data[CONF_USERNAME], data[CONF_PASSWORD], UPDATE_INTERVAL)
3543

3644
await async_validate_input(hass, wallbox)
3745

46+
data[CHARGER_JWT_TOKEN] = wallbox.jwtToken
47+
data[CHARGER_JWT_REFRESH_TOKEN] = wallbox.jwtRefreshToken
48+
data[CHARGER_JWT_TTL] = wallbox.jwtTokenTtl
49+
data[CHARGER_JWT_REFRESH_TTL] = wallbox.jwtRefreshTokenTtl
50+
3851
# Return info that you want to store in the config entry.
39-
return {"title": "Wallbox Portal"}
52+
return {"title": "Wallbox Portal", "data": data}
4053

4154

4255
class WallboxConfigFlow(ConfigFlow, domain=COMPONENT_DOMAIN):
@@ -64,8 +77,11 @@ async def async_step_user(
6477
await self.async_set_unique_id(user_input["station"])
6578
if self.source != SOURCE_REAUTH:
6679
self._abort_if_unique_id_configured()
67-
info = await validate_input(self.hass, user_input)
68-
return self.async_create_entry(title=info["title"], data=user_input)
80+
validation_data = await validate_input(self.hass, user_input)
81+
return self.async_create_entry(
82+
title=validation_data["title"],
83+
data=validation_data["data"],
84+
)
6985
reauth_entry = self._get_reauth_entry()
7086
if user_input["station"] == reauth_entry.data[CONF_STATION]:
7187
return self.async_update_reload_and_abort(reauth_entry, data=user_input)

homeassistant/components/wallbox/const.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@
4747
CHARGER_ECO_SMART_KEY = "ecosmart"
4848
CHARGER_ECO_SMART_STATUS_KEY = "enabled"
4949
CHARGER_ECO_SMART_MODE_KEY = "mode"
50+
CHARGER_WALLBOX_OBJECT_KEY = "wallbox"
51+
52+
CHARGER_JWT_TOKEN = "jwtToken"
53+
CHARGER_JWT_REFRESH_TOKEN = "jwtRefreshToken"
54+
CHARGER_JWT_TTL = "jwtTokenTtl"
55+
CHARGER_JWT_REFRESH_TTL = "jwtRefreshTokenTtl"
5056

5157

5258
class ChargerStatus(StrEnum):

homeassistant/components/wallbox/coordinator.py

Lines changed: 53 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
from collections.abc import Callable
6-
from datetime import timedelta
6+
from datetime import datetime, timedelta
77
from http import HTTPStatus
88
import logging
99
from typing import Any, Concatenate
@@ -27,6 +27,10 @@
2727
CHARGER_ECO_SMART_STATUS_KEY,
2828
CHARGER_ENERGY_PRICE_KEY,
2929
CHARGER_FEATURES_KEY,
30+
CHARGER_JWT_REFRESH_TOKEN,
31+
CHARGER_JWT_REFRESH_TTL,
32+
CHARGER_JWT_TOKEN,
33+
CHARGER_JWT_TTL,
3034
CHARGER_LOCKED_UNLOCKED_KEY,
3135
CHARGER_MAX_CHARGING_CURRENT_KEY,
3236
CHARGER_MAX_CHARGING_CURRENT_POST_KEY,
@@ -86,27 +90,25 @@ def _require_authentication[_WallboxCoordinatorT: WallboxCoordinator, **_P](
8690
) -> Callable[Concatenate[_WallboxCoordinatorT, _P], Any]:
8791
"""Authenticate with decorator using Wallbox API."""
8892

89-
def require_authentication(
93+
async def require_authentication(
9094
self: _WallboxCoordinatorT, *args: _P.args, **kwargs: _P.kwargs
9195
) -> Any:
9296
"""Authenticate using Wallbox API."""
93-
try:
94-
self.authenticate()
95-
return func(self, *args, **kwargs)
96-
except requests.exceptions.HTTPError as wallbox_connection_error:
97-
if wallbox_connection_error.response.status_code == HTTPStatus.FORBIDDEN:
98-
raise ConfigEntryAuthFailed(
99-
translation_domain=DOMAIN, translation_key="invalid_auth"
100-
) from wallbox_connection_error
101-
raise HomeAssistantError(
102-
translation_domain=DOMAIN, translation_key="api_failed"
103-
) from wallbox_connection_error
97+
await self.async_authenticate()
98+
return await func(self, *args, **kwargs)
10499

105100
return require_authentication
106101

107102

103+
def check_token_validity(jwt_token_ttl: int, jwt_token_drift: int) -> bool:
104+
"""Check if the jwtToken is still valid in order to reuse if possible."""
105+
return round((jwt_token_ttl / 1000) - jwt_token_drift, 0) > datetime.timestamp(
106+
datetime.now()
107+
)
108+
109+
108110
def _validate(wallbox: Wallbox) -> None:
109-
"""Authenticate using Wallbox API."""
111+
"""Authenticate using Wallbox API to check if the used credentials are valid."""
110112
try:
111113
wallbox.authenticate()
112114
except requests.exceptions.HTTPError as wallbox_connection_error:
@@ -142,11 +144,38 @@ def __init__(
142144
update_interval=timedelta(seconds=UPDATE_INTERVAL),
143145
)
144146

145-
def authenticate(self) -> None:
147+
def _authenticate(self) -> dict[str, str]:
148+
"""Authenticate using Wallbox API. First check token validity."""
149+
data = dict(self.config_entry.data)
150+
if not check_token_validity(
151+
jwt_token_ttl=data.get(CHARGER_JWT_TTL, 0),
152+
jwt_token_drift=UPDATE_INTERVAL,
153+
):
154+
try:
155+
self._wallbox.authenticate()
156+
except requests.exceptions.HTTPError as wallbox_connection_error:
157+
if (
158+
wallbox_connection_error.response.status_code
159+
== HTTPStatus.FORBIDDEN
160+
):
161+
raise ConfigEntryAuthFailed(
162+
translation_domain=DOMAIN, translation_key="invalid_auth"
163+
) from wallbox_connection_error
164+
raise HomeAssistantError(
165+
translation_domain=DOMAIN, translation_key="api_failed"
166+
) from wallbox_connection_error
167+
else:
168+
data[CHARGER_JWT_TOKEN] = self._wallbox.jwtToken
169+
data[CHARGER_JWT_REFRESH_TOKEN] = self._wallbox.jwtRefreshToken
170+
data[CHARGER_JWT_TTL] = self._wallbox.jwtTokenTtl
171+
data[CHARGER_JWT_REFRESH_TTL] = self._wallbox.jwtRefreshTokenTtl
172+
return data
173+
174+
async def async_authenticate(self) -> None:
146175
"""Authenticate using Wallbox API."""
147-
self._wallbox.authenticate()
176+
data = await self.hass.async_add_executor_job(self._authenticate)
177+
self.hass.config_entries.async_update_entry(self.config_entry, data=data)
148178

149-
@_require_authentication
150179
def _get_data(self) -> dict[str, Any]:
151180
"""Get new sensor data for Wallbox component."""
152181
try:
@@ -208,6 +237,7 @@ def _get_data(self) -> dict[str, Any]:
208237
translation_domain=DOMAIN, translation_key="api_failed"
209238
) from wallbox_connection_error
210239

240+
@_require_authentication
211241
async def _async_update_data(self) -> dict[str, Any]:
212242
"""Get new sensor data for Wallbox component. Set update interval to be UPDATE_INTERVAL * #wallbox chargers configured, this is necessary due to rate limitations."""
213243

@@ -217,7 +247,6 @@ async def _async_update_data(self) -> dict[str, Any]:
217247
)
218248
return await self.hass.async_add_executor_job(self._get_data)
219249

220-
@_require_authentication
221250
def _set_charging_current(
222251
self, charging_current: float
223252
) -> dict[str, dict[str, dict[str, Any]]]:
@@ -246,14 +275,14 @@ def _set_charging_current(
246275
translation_domain=DOMAIN, translation_key="api_failed"
247276
) from wallbox_connection_error
248277

278+
@_require_authentication
249279
async def async_set_charging_current(self, charging_current: float) -> None:
250280
"""Set maximum charging current for Wallbox."""
251281
data = await self.hass.async_add_executor_job(
252282
self._set_charging_current, charging_current
253283
)
254284
self.async_set_updated_data(data)
255285

256-
@_require_authentication
257286
def _set_icp_current(self, icp_current: float) -> dict[str, Any]:
258287
"""Set maximum icp current for Wallbox."""
259288
try:
@@ -276,14 +305,14 @@ def _set_icp_current(self, icp_current: float) -> dict[str, Any]:
276305
translation_domain=DOMAIN, translation_key="api_failed"
277306
) from wallbox_connection_error
278307

308+
@_require_authentication
279309
async def async_set_icp_current(self, icp_current: float) -> None:
280310
"""Set maximum icp current for Wallbox."""
281311
data = await self.hass.async_add_executor_job(
282312
self._set_icp_current, icp_current
283313
)
284314
self.async_set_updated_data(data)
285315

286-
@_require_authentication
287316
def _set_energy_cost(self, energy_cost: float) -> dict[str, Any]:
288317
"""Set energy cost for Wallbox."""
289318
try:
@@ -300,14 +329,14 @@ def _set_energy_cost(self, energy_cost: float) -> dict[str, Any]:
300329
translation_domain=DOMAIN, translation_key="api_failed"
301330
) from wallbox_connection_error
302331

332+
@_require_authentication
303333
async def async_set_energy_cost(self, energy_cost: float) -> None:
304334
"""Set energy cost for Wallbox."""
305335
data = await self.hass.async_add_executor_job(
306336
self._set_energy_cost, energy_cost
307337
)
308338
self.async_set_updated_data(data)
309339

310-
@_require_authentication
311340
def _set_lock_unlock(self, lock: bool) -> dict[str, dict[str, dict[str, Any]]]:
312341
"""Set wallbox to locked or unlocked."""
313342
try:
@@ -335,12 +364,12 @@ def _set_lock_unlock(self, lock: bool) -> dict[str, dict[str, dict[str, Any]]]:
335364
translation_domain=DOMAIN, translation_key="api_failed"
336365
) from wallbox_connection_error
337366

367+
@_require_authentication
338368
async def async_set_lock_unlock(self, lock: bool) -> None:
339369
"""Set wallbox to locked or unlocked."""
340370
data = await self.hass.async_add_executor_job(self._set_lock_unlock, lock)
341371
self.async_set_updated_data(data)
342372

343-
@_require_authentication
344373
def _pause_charger(self, pause: bool) -> None:
345374
"""Set wallbox to pause or resume."""
346375
try:
@@ -357,12 +386,12 @@ def _pause_charger(self, pause: bool) -> None:
357386
translation_domain=DOMAIN, translation_key="api_failed"
358387
) from wallbox_connection_error
359388

389+
@_require_authentication
360390
async def async_pause_charger(self, pause: bool) -> None:
361391
"""Set wallbox to pause or resume."""
362392
await self.hass.async_add_executor_job(self._pause_charger, pause)
363393
await self.async_request_refresh()
364394

365-
@_require_authentication
366395
def _set_eco_smart(self, option: str) -> None:
367396
"""Set wallbox solar charging mode."""
368397
try:
@@ -381,6 +410,7 @@ def _set_eco_smart(self, option: str) -> None:
381410
translation_domain=DOMAIN, translation_key="api_failed"
382411
) from wallbox_connection_error
383412

413+
@_require_authentication
384414
async def async_set_eco_smart(self, option: str) -> None:
385415
"""Set wallbox solar charging mode."""
386416

tests/components/wallbox/conftest.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Test fixtures for the Wallbox integration."""
22

3+
from datetime import datetime, timedelta
34
from http import HTTPStatus
45
from unittest.mock import MagicMock, Mock, patch
56

@@ -10,6 +11,10 @@
1011
CHARGER_DATA_POST_L1_KEY,
1112
CHARGER_DATA_POST_L2_KEY,
1213
CHARGER_ENERGY_PRICE_KEY,
14+
CHARGER_JWT_REFRESH_TOKEN,
15+
CHARGER_JWT_REFRESH_TTL,
16+
CHARGER_JWT_TOKEN,
17+
CHARGER_JWT_TTL,
1318
CHARGER_LOCKED_UNLOCKED_KEY,
1419
CHARGER_MAX_CHARGING_CURRENT_POST_KEY,
1520
CHARGER_MAX_ICP_CURRENT_KEY,
@@ -43,6 +48,14 @@ def entry(hass: HomeAssistant) -> MockConfigEntry:
4348
CONF_USERNAME: "test_username",
4449
CONF_PASSWORD: "test_password",
4550
CONF_STATION: "12345",
51+
CHARGER_JWT_TOKEN: "test_token",
52+
CHARGER_JWT_REFRESH_TOKEN: "test_refresh_token",
53+
CHARGER_JWT_TTL: (
54+
datetime.timestamp(datetime.now() + timedelta(hours=1)) * 1000
55+
),
56+
CHARGER_JWT_REFRESH_TTL: (
57+
datetime.timestamp(datetime.now() + timedelta(hours=1)) * 1000
58+
),
4659
},
4760
entry_id="testEntry",
4861
)
@@ -82,6 +95,14 @@ def mock_wallbox():
8295
)
8396
wallbox.setIcpMaxCurrent = Mock(return_value={CHARGER_MAX_ICP_CURRENT_KEY: 25})
8497
wallbox.getChargerStatus = Mock(return_value=WALLBOX_STATUS_RESPONSE)
98+
wallbox.jwtToken = "test_token"
99+
wallbox.jwtRefreshToken = "test_refresh_token"
100+
wallbox.jwtTokenTtl = (
101+
datetime.timestamp(datetime.now() + timedelta(hours=1)) * 1000
102+
)
103+
wallbox.jwtRefreshTokenTtl = (
104+
datetime.timestamp(datetime.now() + timedelta(hours=1)) * 1000
105+
)
85106
mock.return_value = wallbox
86107
yield wallbox
87108

0 commit comments

Comments
 (0)