mirror of
https://github.com/home-assistant/core.git
synced 2025-07-21 12:17:07 +00:00
Move evohome's API broker to the coordinator module (#118565)
* move Broker to coordinator module * mypy tweak * mypy
This commit is contained in:
parent
62b1bde0e8
commit
d40c940c20
@ -6,13 +6,12 @@ others.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Awaitable
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Final
|
from typing import Any, Final
|
||||||
|
|
||||||
import evohomeasync as ev1
|
import evohomeasync as ev1
|
||||||
from evohomeasync.schema import SZ_ID, SZ_SESSION_ID, SZ_TEMP
|
from evohomeasync.schema import SZ_SESSION_ID
|
||||||
import evohomeasync2 as evo
|
import evohomeasync2 as evo
|
||||||
from evohomeasync2.schema.const import (
|
from evohomeasync2.schema.const import (
|
||||||
SZ_ALLOWED_SYSTEM_MODES,
|
SZ_ALLOWED_SYSTEM_MODES,
|
||||||
@ -51,14 +50,13 @@ from homeassistant.helpers.dispatcher import (
|
|||||||
async_dispatcher_send,
|
async_dispatcher_send,
|
||||||
)
|
)
|
||||||
from homeassistant.helpers.entity import Entity
|
from homeassistant.helpers.entity import Entity
|
||||||
from homeassistant.helpers.event import async_call_later, async_track_time_interval
|
from homeassistant.helpers.event import async_track_time_interval
|
||||||
from homeassistant.helpers.service import verify_domain_control
|
from homeassistant.helpers.service import verify_domain_control
|
||||||
from homeassistant.helpers.storage import Store
|
from homeassistant.helpers.storage import Store
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
import homeassistant.util.dt as dt_util
|
import homeassistant.util.dt as dt_util
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
ACCESS_TOKEN,
|
|
||||||
ACCESS_TOKEN_EXPIRES,
|
ACCESS_TOKEN_EXPIRES,
|
||||||
ATTR_DURATION_DAYS,
|
ATTR_DURATION_DAYS,
|
||||||
ATTR_DURATION_HOURS,
|
ATTR_DURATION_HOURS,
|
||||||
@ -68,21 +66,19 @@ from .const import (
|
|||||||
CONF_LOCATION_IDX,
|
CONF_LOCATION_IDX,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
GWS,
|
GWS,
|
||||||
REFRESH_TOKEN,
|
|
||||||
SCAN_INTERVAL_DEFAULT,
|
SCAN_INTERVAL_DEFAULT,
|
||||||
SCAN_INTERVAL_MINIMUM,
|
SCAN_INTERVAL_MINIMUM,
|
||||||
STORAGE_KEY,
|
STORAGE_KEY,
|
||||||
STORAGE_VER,
|
STORAGE_VER,
|
||||||
TCS,
|
TCS,
|
||||||
USER_DATA,
|
USER_DATA,
|
||||||
UTC_OFFSET,
|
|
||||||
EvoService,
|
EvoService,
|
||||||
)
|
)
|
||||||
|
from .coordinator import EvoBroker
|
||||||
from .helpers import (
|
from .helpers import (
|
||||||
convert_dict,
|
convert_dict,
|
||||||
convert_until,
|
convert_until,
|
||||||
dt_aware_to_naive,
|
dt_aware_to_naive,
|
||||||
dt_local_to_aware,
|
|
||||||
handle_evo_exception,
|
handle_evo_exception,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -161,6 +157,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
finally:
|
finally:
|
||||||
config[DOMAIN][CONF_PASSWORD] = "REDACTED"
|
config[DOMAIN][CONF_PASSWORD] = "REDACTED"
|
||||||
|
|
||||||
|
assert isinstance(client_v2.installation_info, list) # mypy
|
||||||
|
|
||||||
loc_idx = config[DOMAIN][CONF_LOCATION_IDX]
|
loc_idx = config[DOMAIN][CONF_LOCATION_IDX]
|
||||||
try:
|
try:
|
||||||
loc_config = client_v2.installation_info[loc_idx]
|
loc_config = client_v2.installation_info[loc_idx]
|
||||||
@ -342,161 +340,6 @@ def setup_service_functions(hass: HomeAssistant, broker: EvoBroker) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class EvoBroker:
|
|
||||||
"""Container for evohome client and data."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
hass: HomeAssistant,
|
|
||||||
client: evo.EvohomeClient,
|
|
||||||
client_v1: ev1.EvohomeClient | None,
|
|
||||||
store: Store[dict[str, Any]],
|
|
||||||
params: ConfigType,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize the evohome client and its data structure."""
|
|
||||||
self.hass = hass
|
|
||||||
self.client = client
|
|
||||||
self.client_v1 = client_v1
|
|
||||||
self._store = store
|
|
||||||
self.params = params
|
|
||||||
|
|
||||||
loc_idx = params[CONF_LOCATION_IDX]
|
|
||||||
self._location: evo.Location = client.locations[loc_idx]
|
|
||||||
|
|
||||||
self.config = client.installation_info[loc_idx][GWS][0][TCS][0]
|
|
||||||
self.tcs: evo.ControlSystem = self._location._gateways[0]._control_systems[0] # noqa: SLF001
|
|
||||||
self.loc_utc_offset = timedelta(minutes=self._location.timeZone[UTC_OFFSET])
|
|
||||||
self.temps: dict[str, float | None] = {}
|
|
||||||
|
|
||||||
async def save_auth_tokens(self) -> None:
|
|
||||||
"""Save access tokens and session IDs to the store for later use."""
|
|
||||||
# evohomeasync2 uses naive/local datetimes
|
|
||||||
access_token_expires = dt_local_to_aware(
|
|
||||||
self.client.access_token_expires # type: ignore[arg-type]
|
|
||||||
)
|
|
||||||
|
|
||||||
app_storage: dict[str, Any] = {
|
|
||||||
CONF_USERNAME: self.client.username,
|
|
||||||
REFRESH_TOKEN: self.client.refresh_token,
|
|
||||||
ACCESS_TOKEN: self.client.access_token,
|
|
||||||
ACCESS_TOKEN_EXPIRES: access_token_expires.isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.client_v1:
|
|
||||||
app_storage[USER_DATA] = {
|
|
||||||
SZ_SESSION_ID: self.client_v1.broker.session_id,
|
|
||||||
} # this is the schema for STORAGE_VER == 1
|
|
||||||
else:
|
|
||||||
app_storage[USER_DATA] = {}
|
|
||||||
|
|
||||||
await self._store.async_save(app_storage)
|
|
||||||
|
|
||||||
async def call_client_api(
|
|
||||||
self,
|
|
||||||
client_api: Awaitable[dict[str, Any] | None],
|
|
||||||
update_state: bool = True,
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
"""Call a client API and update the broker state if required."""
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await client_api
|
|
||||||
except evo.RequestFailed as err:
|
|
||||||
handle_evo_exception(err)
|
|
||||||
return None
|
|
||||||
|
|
||||||
if update_state: # wait a moment for system to quiesce before updating state
|
|
||||||
async_call_later(self.hass, 1, self._update_v2_api_state)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
async def _update_v1_api_temps(self) -> None:
|
|
||||||
"""Get the latest high-precision temperatures of the default Location."""
|
|
||||||
|
|
||||||
assert self.client_v1 is not None # mypy check
|
|
||||||
|
|
||||||
def get_session_id(client_v1: ev1.EvohomeClient) -> str | None:
|
|
||||||
user_data = client_v1.user_data if client_v1 else None
|
|
||||||
return user_data.get(SZ_SESSION_ID) if user_data else None # type: ignore[return-value]
|
|
||||||
|
|
||||||
session_id = get_session_id(self.client_v1)
|
|
||||||
|
|
||||||
try:
|
|
||||||
temps = await self.client_v1.get_temperatures()
|
|
||||||
|
|
||||||
except ev1.InvalidSchema as err:
|
|
||||||
_LOGGER.warning(
|
|
||||||
(
|
|
||||||
"Unable to obtain high-precision temperatures. "
|
|
||||||
"It appears the JSON schema is not as expected, "
|
|
||||||
"so the high-precision feature will be disabled until next restart."
|
|
||||||
"Message is: %s"
|
|
||||||
),
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
self.client_v1 = None
|
|
||||||
|
|
||||||
except ev1.RequestFailed as err:
|
|
||||||
_LOGGER.warning(
|
|
||||||
(
|
|
||||||
"Unable to obtain the latest high-precision temperatures. "
|
|
||||||
"Check your network and the vendor's service status page. "
|
|
||||||
"Proceeding without high-precision temperatures for now. "
|
|
||||||
"Message is: %s"
|
|
||||||
),
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
self.temps = {} # high-precision temps now considered stale
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
self.temps = {} # high-precision temps now considered stale
|
|
||||||
raise
|
|
||||||
|
|
||||||
else:
|
|
||||||
if str(self.client_v1.location_id) != self._location.locationId:
|
|
||||||
_LOGGER.warning(
|
|
||||||
"The v2 API's configured location doesn't match "
|
|
||||||
"the v1 API's default location (there is more than one location), "
|
|
||||||
"so the high-precision feature will be disabled until next restart"
|
|
||||||
)
|
|
||||||
self.client_v1 = None
|
|
||||||
else:
|
|
||||||
self.temps = {str(i[SZ_ID]): i[SZ_TEMP] for i in temps}
|
|
||||||
|
|
||||||
finally:
|
|
||||||
if self.client_v1 and session_id != self.client_v1.broker.session_id:
|
|
||||||
await self.save_auth_tokens()
|
|
||||||
|
|
||||||
_LOGGER.debug("Temperatures = %s", self.temps)
|
|
||||||
|
|
||||||
async def _update_v2_api_state(self, *args: Any) -> None:
|
|
||||||
"""Get the latest modes, temperatures, setpoints of a Location."""
|
|
||||||
|
|
||||||
access_token = self.client.access_token # maybe receive a new token?
|
|
||||||
|
|
||||||
try:
|
|
||||||
status = await self._location.refresh_status()
|
|
||||||
except evo.RequestFailed as err:
|
|
||||||
handle_evo_exception(err)
|
|
||||||
else:
|
|
||||||
async_dispatcher_send(self.hass, DOMAIN)
|
|
||||||
_LOGGER.debug("Status = %s", status)
|
|
||||||
finally:
|
|
||||||
if access_token != self.client.access_token:
|
|
||||||
await self.save_auth_tokens()
|
|
||||||
|
|
||||||
async def async_update(self, *args: Any) -> None:
|
|
||||||
"""Get the latest state data of an entire Honeywell TCC Location.
|
|
||||||
|
|
||||||
This includes state data for a Controller and all its child devices, such as the
|
|
||||||
operating mode of the Controller and the current temp of its children (e.g.
|
|
||||||
Zones, DHW controller).
|
|
||||||
"""
|
|
||||||
await self._update_v2_api_state()
|
|
||||||
|
|
||||||
if self.client_v1:
|
|
||||||
await self._update_v1_api_temps()
|
|
||||||
|
|
||||||
|
|
||||||
class EvoDevice(Entity):
|
class EvoDevice(Entity):
|
||||||
"""Base for any evohome device.
|
"""Base for any evohome device.
|
||||||
|
|
||||||
|
191
homeassistant/components/evohome/coordinator.py
Normal file
191
homeassistant/components/evohome/coordinator.py
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
"""Support for (EMEA/EU-based) Honeywell TCC systems."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Awaitable
|
||||||
|
from datetime import timedelta
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import evohomeasync as ev1
|
||||||
|
from evohomeasync.schema import SZ_ID, SZ_SESSION_ID, SZ_TEMP
|
||||||
|
import evohomeasync2 as evo
|
||||||
|
|
||||||
|
from homeassistant.const import CONF_USERNAME
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||||
|
from homeassistant.helpers.event import async_call_later
|
||||||
|
from homeassistant.helpers.storage import Store
|
||||||
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
ACCESS_TOKEN,
|
||||||
|
ACCESS_TOKEN_EXPIRES,
|
||||||
|
CONF_LOCATION_IDX,
|
||||||
|
DOMAIN,
|
||||||
|
GWS,
|
||||||
|
REFRESH_TOKEN,
|
||||||
|
TCS,
|
||||||
|
USER_DATA,
|
||||||
|
UTC_OFFSET,
|
||||||
|
)
|
||||||
|
from .helpers import dt_local_to_aware, handle_evo_exception
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__.rpartition(".")[0])
|
||||||
|
|
||||||
|
|
||||||
|
class EvoBroker:
|
||||||
|
"""Container for evohome client and data."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
client: evo.EvohomeClient,
|
||||||
|
client_v1: ev1.EvohomeClient | None,
|
||||||
|
store: Store[dict[str, Any]],
|
||||||
|
params: ConfigType,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the evohome client and its data structure."""
|
||||||
|
self.hass = hass
|
||||||
|
self.client = client
|
||||||
|
self.client_v1 = client_v1
|
||||||
|
self._store = store
|
||||||
|
self.params = params
|
||||||
|
|
||||||
|
loc_idx = params[CONF_LOCATION_IDX]
|
||||||
|
self._location: evo.Location = client.locations[loc_idx]
|
||||||
|
|
||||||
|
assert isinstance(client.installation_info, list) # mypy
|
||||||
|
|
||||||
|
self.config = client.installation_info[loc_idx][GWS][0][TCS][0]
|
||||||
|
self.tcs: evo.ControlSystem = self._location._gateways[0]._control_systems[0] # noqa: SLF001
|
||||||
|
self.loc_utc_offset = timedelta(minutes=self._location.timeZone[UTC_OFFSET])
|
||||||
|
self.temps: dict[str, float | None] = {}
|
||||||
|
|
||||||
|
async def save_auth_tokens(self) -> None:
|
||||||
|
"""Save access tokens and session IDs to the store for later use."""
|
||||||
|
# evohomeasync2 uses naive/local datetimes
|
||||||
|
access_token_expires = dt_local_to_aware(
|
||||||
|
self.client.access_token_expires # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
app_storage: dict[str, Any] = {
|
||||||
|
CONF_USERNAME: self.client.username,
|
||||||
|
REFRESH_TOKEN: self.client.refresh_token,
|
||||||
|
ACCESS_TOKEN: self.client.access_token,
|
||||||
|
ACCESS_TOKEN_EXPIRES: access_token_expires.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.client_v1:
|
||||||
|
app_storage[USER_DATA] = {
|
||||||
|
SZ_SESSION_ID: self.client_v1.broker.session_id,
|
||||||
|
} # this is the schema for STORAGE_VER == 1
|
||||||
|
else:
|
||||||
|
app_storage[USER_DATA] = {}
|
||||||
|
|
||||||
|
await self._store.async_save(app_storage)
|
||||||
|
|
||||||
|
async def call_client_api(
|
||||||
|
self,
|
||||||
|
client_api: Awaitable[dict[str, Any] | None],
|
||||||
|
update_state: bool = True,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Call a client API and update the broker state if required."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await client_api
|
||||||
|
except evo.RequestFailed as err:
|
||||||
|
handle_evo_exception(err)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if update_state: # wait a moment for system to quiesce before updating state
|
||||||
|
async_call_later(self.hass, 1, self._update_v2_api_state)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _update_v1_api_temps(self) -> None:
|
||||||
|
"""Get the latest high-precision temperatures of the default Location."""
|
||||||
|
|
||||||
|
assert self.client_v1 is not None # mypy check
|
||||||
|
|
||||||
|
def get_session_id(client_v1: ev1.EvohomeClient) -> str | None:
|
||||||
|
user_data = client_v1.user_data if client_v1 else None
|
||||||
|
return user_data.get(SZ_SESSION_ID) if user_data else None # type: ignore[return-value]
|
||||||
|
|
||||||
|
session_id = get_session_id(self.client_v1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
temps = await self.client_v1.get_temperatures()
|
||||||
|
|
||||||
|
except ev1.InvalidSchema as err:
|
||||||
|
_LOGGER.warning(
|
||||||
|
(
|
||||||
|
"Unable to obtain high-precision temperatures. "
|
||||||
|
"It appears the JSON schema is not as expected, "
|
||||||
|
"so the high-precision feature will be disabled until next restart."
|
||||||
|
"Message is: %s"
|
||||||
|
),
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
self.client_v1 = None
|
||||||
|
|
||||||
|
except ev1.RequestFailed as err:
|
||||||
|
_LOGGER.warning(
|
||||||
|
(
|
||||||
|
"Unable to obtain the latest high-precision temperatures. "
|
||||||
|
"Check your network and the vendor's service status page. "
|
||||||
|
"Proceeding without high-precision temperatures for now. "
|
||||||
|
"Message is: %s"
|
||||||
|
),
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
self.temps = {} # high-precision temps now considered stale
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
self.temps = {} # high-precision temps now considered stale
|
||||||
|
raise
|
||||||
|
|
||||||
|
else:
|
||||||
|
if str(self.client_v1.location_id) != self._location.locationId:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"The v2 API's configured location doesn't match "
|
||||||
|
"the v1 API's default location (there is more than one location), "
|
||||||
|
"so the high-precision feature will be disabled until next restart"
|
||||||
|
)
|
||||||
|
self.client_v1 = None
|
||||||
|
else:
|
||||||
|
self.temps = {str(i[SZ_ID]): i[SZ_TEMP] for i in temps}
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if self.client_v1 and session_id != self.client_v1.broker.session_id:
|
||||||
|
await self.save_auth_tokens()
|
||||||
|
|
||||||
|
_LOGGER.debug("Temperatures = %s", self.temps)
|
||||||
|
|
||||||
|
async def _update_v2_api_state(self, *args: Any) -> None:
|
||||||
|
"""Get the latest modes, temperatures, setpoints of a Location."""
|
||||||
|
|
||||||
|
access_token = self.client.access_token # maybe receive a new token?
|
||||||
|
|
||||||
|
try:
|
||||||
|
status = await self._location.refresh_status()
|
||||||
|
except evo.RequestFailed as err:
|
||||||
|
handle_evo_exception(err)
|
||||||
|
else:
|
||||||
|
async_dispatcher_send(self.hass, DOMAIN)
|
||||||
|
_LOGGER.debug("Status = %s", status)
|
||||||
|
finally:
|
||||||
|
if access_token != self.client.access_token:
|
||||||
|
await self.save_auth_tokens()
|
||||||
|
|
||||||
|
async def async_update(self, *args: Any) -> None:
|
||||||
|
"""Get the latest state data of an entire Honeywell TCC Location.
|
||||||
|
|
||||||
|
This includes state data for a Controller and all its child devices, such as the
|
||||||
|
operating mode of the Controller and the current temp of its children (e.g.
|
||||||
|
Zones, DHW controller).
|
||||||
|
"""
|
||||||
|
await self._update_v2_api_state()
|
||||||
|
|
||||||
|
if self.client_v1:
|
||||||
|
await self._update_v1_api_temps()
|
Loading…
x
Reference in New Issue
Block a user