Fix/Rewrite of Toon integration (#36952)
Co-authored-by: J. Nick Koston <nick@koston.org> Co-authored-by: Paulus Schoutsen <balloob@gmail.com>pull/37030/head
parent
c28493098a
commit
8b21b415c4
11
.coveragerc
11
.coveragerc
|
@ -822,7 +822,16 @@ omit =
|
|||
homeassistant/components/todoist/const.py
|
||||
homeassistant/components/tof/sensor.py
|
||||
homeassistant/components/tomato/device_tracker.py
|
||||
homeassistant/components/toon/*
|
||||
homeassistant/components/toon/__init__.py
|
||||
homeassistant/components/toon/binary_sensor.py
|
||||
homeassistant/components/toon/climate.py
|
||||
homeassistant/components/toon/const.py
|
||||
homeassistant/components/toon/coordinator.py
|
||||
homeassistant/components/toon/helpers.py
|
||||
homeassistant/components/toon/models.py
|
||||
homeassistant/components/toon/oauth2.py
|
||||
homeassistant/components/toon/sensor.py
|
||||
homeassistant/components/toon/switch.py
|
||||
homeassistant/components/torque/sensor.py
|
||||
homeassistant/components/totalconnect/*
|
||||
homeassistant/components/touchline/climate.py
|
||||
|
|
|
@ -1,289 +1,159 @@
|
|||
"""Support for Toon van Eneco devices."""
|
||||
from functools import partial
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
from toonapilib import Toon
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
|
||||
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_CLIENT_ID,
|
||||
CONF_CLIENT_SECRET,
|
||||
CONF_PASSWORD,
|
||||
CONF_SCAN_INTERVAL,
|
||||
CONF_USERNAME,
|
||||
EVENT_HOMEASSISTANT_STARTED,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.core import CoreState, HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
|
||||
|
||||
from . import config_flow # noqa: F401
|
||||
from .const import (
|
||||
CONF_DISPLAY,
|
||||
CONF_TENANT,
|
||||
DATA_TOON,
|
||||
DATA_TOON_CLIENT,
|
||||
DATA_TOON_CONFIG,
|
||||
DATA_TOON_UPDATED,
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
DOMAIN,
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
OAuth2Session,
|
||||
async_get_config_entry_implementation,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_AGREEMENT_ID, CONF_MIGRATE, DEFAULT_SCAN_INTERVAL, DOMAIN
|
||||
from .coordinator import ToonDataUpdateCoordinator
|
||||
from .oauth2 import register_oauth2_implementations
|
||||
|
||||
ENTITY_COMPONENTS = {
|
||||
BINARY_SENSOR_DOMAIN,
|
||||
CLIMATE_DOMAIN,
|
||||
SENSOR_DOMAIN,
|
||||
SWITCH_DOMAIN,
|
||||
}
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Validation of the user's configuration
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
DOMAIN: vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_CLIENT_ID): cv.string,
|
||||
vol.Required(CONF_CLIENT_SECRET): cv.string,
|
||||
vol.Required(
|
||||
CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL
|
||||
): vol.All(cv.time_period, cv.positive_timedelta),
|
||||
}
|
||||
DOMAIN: vol.All(
|
||||
cv.deprecated(CONF_SCAN_INTERVAL),
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_CLIENT_ID): cv.string,
|
||||
vol.Required(CONF_CLIENT_SECRET): cv.string,
|
||||
vol.Optional(
|
||||
CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL
|
||||
): vol.All(cv.time_period, cv.positive_timedelta),
|
||||
}
|
||||
),
|
||||
)
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
SERVICE_SCHEMA = vol.Schema({vol.Optional(CONF_DISPLAY): cv.string})
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool:
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Toon components."""
|
||||
if DOMAIN not in config:
|
||||
return True
|
||||
|
||||
conf = config[DOMAIN]
|
||||
register_oauth2_implementations(
|
||||
hass, config[DOMAIN][CONF_CLIENT_ID], config[DOMAIN][CONF_CLIENT_SECRET]
|
||||
)
|
||||
|
||||
# Store config to be used during entry setup
|
||||
hass.data[DATA_TOON_CONFIG] = conf
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_IMPORT})
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistantType, entry: ConfigType) -> bool:
|
||||
"""Set up Toon from a config entry."""
|
||||
|
||||
conf = hass.data.get(DATA_TOON_CONFIG)
|
||||
|
||||
toon = await hass.async_add_executor_job(
|
||||
partial(
|
||||
Toon,
|
||||
entry.data[CONF_USERNAME],
|
||||
entry.data[CONF_PASSWORD],
|
||||
conf[CONF_CLIENT_ID],
|
||||
conf[CONF_CLIENT_SECRET],
|
||||
tenant_id=entry.data[CONF_TENANT],
|
||||
display_common_name=entry.data[CONF_DISPLAY],
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Handle migration of a previous version config entry."""
|
||||
if entry.version == 1:
|
||||
# There is no usable data in version 1 anymore.
|
||||
# The integration switched to OAuth and because of this, uses
|
||||
# different unique identifiers as well.
|
||||
# Force this by removing the existing entry and trigger a new flow.
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_IMPORT},
|
||||
data={CONF_MIGRATE: entry.entry_id},
|
||||
)
|
||||
)
|
||||
)
|
||||
hass.data.setdefault(DATA_TOON_CLIENT, {})[entry.entry_id] = toon
|
||||
return False
|
||||
|
||||
toon_data = await hass.async_add_executor_job(ToonData, hass, entry, toon)
|
||||
hass.data.setdefault(DATA_TOON, {})[entry.entry_id] = toon_data
|
||||
async_track_time_interval(hass, toon_data.update, conf[CONF_SCAN_INTERVAL])
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Toon from a config entry."""
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
session = OAuth2Session(hass, entry, implementation)
|
||||
|
||||
coordinator = ToonDataUpdateCoordinator(hass, entry=entry, session=session)
|
||||
await coordinator.toon.activate_agreement(
|
||||
agreement_id=entry.data[CONF_AGREEMENT_ID]
|
||||
)
|
||||
await coordinator.async_refresh()
|
||||
|
||||
if not coordinator.last_update_success:
|
||||
raise ConfigEntryNotReady
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hass.data[DOMAIN][entry.entry_id] = coordinator
|
||||
|
||||
# Register device for the Meter Adapter, since it will have no entities.
|
||||
device_registry = await dr.async_get_registry(hass)
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, toon.agreement.id, "meter_adapter")},
|
||||
identifiers={
|
||||
(DOMAIN, coordinator.data.agreement.agreement_id, "meter_adapter")
|
||||
},
|
||||
manufacturer="Eneco",
|
||||
name="Meter Adapter",
|
||||
via_device=(DOMAIN, toon.agreement.id),
|
||||
via_device=(DOMAIN, coordinator.data.agreement.agreement_id),
|
||||
)
|
||||
|
||||
def update(call):
|
||||
"""Service call to manually update the data."""
|
||||
called_display = call.data.get(CONF_DISPLAY)
|
||||
for toon_data in hass.data[DATA_TOON].values():
|
||||
if (
|
||||
called_display and called_display == toon_data.display_name
|
||||
) or not called_display:
|
||||
toon_data.update()
|
||||
|
||||
hass.services.async_register(DOMAIN, "update", update, schema=SERVICE_SCHEMA)
|
||||
|
||||
for component in "binary_sensor", "climate", "sensor":
|
||||
# Spin up the platforms
|
||||
for component in ENTITY_COMPONENTS:
|
||||
hass.async_create_task(
|
||||
hass.config_entries.async_forward_entry_setup(entry, component)
|
||||
)
|
||||
|
||||
# If Home Assistant is already in a running state, register the webhook
|
||||
# immediately, else trigger it after Home Assistant has finished starting.
|
||||
if hass.state == CoreState.running:
|
||||
await coordinator.register_webhook()
|
||||
else:
|
||||
hass.bus.async_listen_once(
|
||||
EVENT_HOMEASSISTANT_STARTED, coordinator.register_webhook
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class ToonData:
|
||||
"""Communication class for interacting with toonapilib."""
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload Toon config entry."""
|
||||
|
||||
def __init__(self, hass: HomeAssistantType, entry: ConfigType, toon):
|
||||
"""Initialize the Toon data object."""
|
||||
self._hass = hass
|
||||
self._toon = toon
|
||||
self._entry = entry
|
||||
self.agreement = toon.agreement
|
||||
self.gas = toon.gas
|
||||
self.power = toon.power
|
||||
self.solar = toon.solar
|
||||
self.temperature = toon.temperature
|
||||
self.thermostat = toon.thermostat
|
||||
self.thermostat_info = toon.thermostat_info
|
||||
self.thermostat_state = toon.thermostat_state
|
||||
# Remove webhooks registration
|
||||
await hass.data[DOMAIN][entry.entry_id].unregister_webhook()
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
"""Return the display connected to."""
|
||||
return self._entry.data[CONF_DISPLAY]
|
||||
|
||||
def update(self, now=None):
|
||||
"""Update all Toon data and notify entities."""
|
||||
# Ignore the TTL mechanism from client library
|
||||
# It causes a lots of issues, hence we take control over caching
|
||||
self._toon._clear_cache() # pylint: disable=protected-access
|
||||
|
||||
# Gather data from client library (single API call)
|
||||
self.gas = self._toon.gas
|
||||
self.power = self._toon.power
|
||||
self.solar = self._toon.solar
|
||||
self.temperature = self._toon.temperature
|
||||
self.thermostat = self._toon.thermostat
|
||||
self.thermostat_info = self._toon.thermostat_info
|
||||
self.thermostat_state = self._toon.thermostat_state
|
||||
|
||||
# Notify all entities
|
||||
dispatcher_send(self._hass, DATA_TOON_UPDATED, self._entry.data[CONF_DISPLAY])
|
||||
|
||||
|
||||
class ToonEntity(Entity):
|
||||
"""Defines a base Toon entity."""
|
||||
|
||||
def __init__(self, toon: ToonData, name: str, icon: str) -> None:
|
||||
"""Initialize the Toon entity."""
|
||||
self._name = name
|
||||
self._state = None
|
||||
self._icon = icon
|
||||
self.toon = toon
|
||||
self._unsub_dispatcher = None
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the name of the entity."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def icon(self) -> str:
|
||||
"""Return the mdi icon of the entity."""
|
||||
return self._icon
|
||||
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
"""Return the polling requirement of the entity."""
|
||||
return False
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Connect to dispatcher listening for entity data notifications."""
|
||||
self._unsub_dispatcher = async_dispatcher_connect(
|
||||
self.hass, DATA_TOON_UPDATED, self._schedule_immediate_update
|
||||
# Unload entities for this entry/device.
|
||||
await asyncio.gather(
|
||||
*(
|
||||
hass.config_entries.async_forward_entry_unload(entry, component)
|
||||
for component in ENTITY_COMPONENTS
|
||||
)
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Disconnect from update signal."""
|
||||
self._unsub_dispatcher()
|
||||
# Cleanup
|
||||
del hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
@callback
|
||||
def _schedule_immediate_update(self, display_name: str) -> None:
|
||||
"""Schedule an immediate update of the entity."""
|
||||
if display_name == self.toon.display_name:
|
||||
self.async_schedule_update_ha_state(True)
|
||||
|
||||
|
||||
class ToonDisplayDeviceEntity(ToonEntity):
|
||||
"""Defines a Toon display device entity."""
|
||||
|
||||
@property
|
||||
def device_info(self) -> Dict[str, Any]:
|
||||
"""Return device information about this thermostat."""
|
||||
agreement = self.toon.agreement
|
||||
model = agreement.display_hardware_version.rpartition("/")[0]
|
||||
sw_version = agreement.display_software_version.rpartition("/")[-1]
|
||||
return {
|
||||
"identifiers": {(DOMAIN, agreement.id)},
|
||||
"name": "Toon Display",
|
||||
"manufacturer": "Eneco",
|
||||
"model": model,
|
||||
"sw_version": sw_version,
|
||||
}
|
||||
|
||||
|
||||
class ToonElectricityMeterDeviceEntity(ToonEntity):
|
||||
"""Defines a Electricity Meter device entity."""
|
||||
|
||||
@property
|
||||
def device_info(self) -> Dict[str, Any]:
|
||||
"""Return device information about this entity."""
|
||||
return {
|
||||
"name": "Electricity Meter",
|
||||
"identifiers": {(DOMAIN, self.toon.agreement.id, "electricity")},
|
||||
"via_device": (DOMAIN, self.toon.agreement.id, "meter_adapter"),
|
||||
}
|
||||
|
||||
|
||||
class ToonGasMeterDeviceEntity(ToonEntity):
|
||||
"""Defines a Gas Meter device entity."""
|
||||
|
||||
@property
|
||||
def device_info(self) -> Dict[str, Any]:
|
||||
"""Return device information about this entity."""
|
||||
via_device = "meter_adapter"
|
||||
if self.toon.gas.is_smart:
|
||||
via_device = "electricity"
|
||||
|
||||
return {
|
||||
"name": "Gas Meter",
|
||||
"identifiers": {(DOMAIN, self.toon.agreement.id, "gas")},
|
||||
"via_device": (DOMAIN, self.toon.agreement.id, via_device),
|
||||
}
|
||||
|
||||
|
||||
class ToonSolarDeviceEntity(ToonEntity):
|
||||
"""Defines a Solar Device device entity."""
|
||||
|
||||
@property
|
||||
def device_info(self) -> Dict[str, Any]:
|
||||
"""Return device information about this entity."""
|
||||
return {
|
||||
"name": "Solar Panels",
|
||||
"identifiers": {(DOMAIN, self.toon.agreement.id, "solar")},
|
||||
"via_device": (DOMAIN, self.toon.agreement.id, "meter_adapter"),
|
||||
}
|
||||
|
||||
|
||||
class ToonBoilerModuleDeviceEntity(ToonEntity):
|
||||
"""Defines a Boiler Module device entity."""
|
||||
|
||||
@property
|
||||
def device_info(self) -> Dict[str, Any]:
|
||||
"""Return device information about this entity."""
|
||||
return {
|
||||
"name": "Boiler Module",
|
||||
"manufacturer": "Eneco",
|
||||
"identifiers": {(DOMAIN, self.toon.agreement.id, "boiler_module")},
|
||||
"via_device": (DOMAIN, self.toon.agreement.id),
|
||||
}
|
||||
|
||||
|
||||
class ToonBoilerDeviceEntity(ToonEntity):
|
||||
"""Defines a Boiler device entity."""
|
||||
|
||||
@property
|
||||
def device_info(self) -> Dict[str, Any]:
|
||||
"""Return device information about this entity."""
|
||||
return {
|
||||
"name": "Boiler",
|
||||
"identifiers": {(DOMAIN, self.toon.agreement.id, "boiler")},
|
||||
"via_device": (DOMAIN, self.toon.agreement.id, "boiler_module"),
|
||||
}
|
||||
return True
|
||||
|
|
|
@ -1,20 +1,29 @@
|
|||
"""Support for Toon binary sensors."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Optional
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
|
||||
from . import (
|
||||
from .const import (
|
||||
ATTR_DEFAULT_ENABLED,
|
||||
ATTR_DEVICE_CLASS,
|
||||
ATTR_ICON,
|
||||
ATTR_INVERTED,
|
||||
ATTR_MEASUREMENT,
|
||||
ATTR_NAME,
|
||||
ATTR_SECTION,
|
||||
BINARY_SENSOR_ENTITIES,
|
||||
DOMAIN,
|
||||
)
|
||||
from .coordinator import ToonDataUpdateCoordinator
|
||||
from .models import (
|
||||
ToonBoilerDeviceEntity,
|
||||
ToonBoilerModuleDeviceEntity,
|
||||
ToonData,
|
||||
ToonDisplayDeviceEntity,
|
||||
ToonEntity,
|
||||
)
|
||||
from .const import DATA_TOON, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
@ -23,87 +32,27 @@ async def async_setup_entry(
|
|||
hass: HomeAssistantType, entry: ConfigEntry, async_add_entities
|
||||
) -> None:
|
||||
"""Set up a Toon binary sensor based on a config entry."""
|
||||
toon = hass.data[DATA_TOON][entry.entry_id]
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
sensors = [
|
||||
ToonBoilerModuleBinarySensor(
|
||||
toon,
|
||||
"thermostat_info",
|
||||
"boiler_connected",
|
||||
None,
|
||||
"Boiler Module Connection",
|
||||
"mdi:check-network-outline",
|
||||
"connectivity",
|
||||
),
|
||||
ToonDisplayBinarySensor(
|
||||
toon,
|
||||
"thermostat_info",
|
||||
"active_state",
|
||||
4,
|
||||
"Toon Holiday Mode",
|
||||
"mdi:airport",
|
||||
None,
|
||||
),
|
||||
ToonDisplayBinarySensor(
|
||||
toon,
|
||||
"thermostat_info",
|
||||
"next_program",
|
||||
None,
|
||||
"Toon Program",
|
||||
"mdi:calendar-clock",
|
||||
None,
|
||||
coordinator, key="thermostat_info_boiler_connected_None"
|
||||
),
|
||||
ToonDisplayBinarySensor(coordinator, key="thermostat_program_overridden"),
|
||||
]
|
||||
|
||||
if toon.thermostat_info.have_ot_boiler:
|
||||
if coordinator.data.thermostat.have_opentherm_boiler:
|
||||
sensors.extend(
|
||||
[
|
||||
ToonBoilerBinarySensor(
|
||||
toon,
|
||||
"thermostat_info",
|
||||
"ot_communication_error",
|
||||
"0",
|
||||
"OpenTherm Connection",
|
||||
"mdi:check-network-outline",
|
||||
"connectivity",
|
||||
),
|
||||
ToonBoilerBinarySensor(
|
||||
toon,
|
||||
"thermostat_info",
|
||||
"error_found",
|
||||
255,
|
||||
"Boiler Status",
|
||||
"mdi:alert",
|
||||
"problem",
|
||||
inverted=True,
|
||||
),
|
||||
ToonBoilerBinarySensor(
|
||||
toon,
|
||||
"thermostat_info",
|
||||
"burner_info",
|
||||
None,
|
||||
"Boiler Burner",
|
||||
"mdi:fire",
|
||||
None,
|
||||
),
|
||||
ToonBoilerBinarySensor(
|
||||
toon,
|
||||
"thermostat_info",
|
||||
"burner_info",
|
||||
"2",
|
||||
"Hot Tap Water",
|
||||
"mdi:water-pump",
|
||||
None,
|
||||
),
|
||||
ToonBoilerBinarySensor(
|
||||
toon,
|
||||
"thermostat_info",
|
||||
"burner_info",
|
||||
"3",
|
||||
"Boiler Preheating",
|
||||
"mdi:fire",
|
||||
None,
|
||||
),
|
||||
ToonBoilerBinarySensor(coordinator, key=key)
|
||||
for key in [
|
||||
"thermostat_info_ot_communication_error_0",
|
||||
"thermostat_info_error_found_255",
|
||||
"thermostat_info_burner_info_None",
|
||||
"thermostat_info_burner_info_1",
|
||||
"thermostat_info_burner_info_2",
|
||||
"thermostat_info_burner_info_3",
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
|
@ -113,66 +62,46 @@ async def async_setup_entry(
|
|||
class ToonBinarySensor(ToonEntity, BinarySensorEntity):
|
||||
"""Defines an Toon binary sensor."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
toon: ToonData,
|
||||
section: str,
|
||||
measurement: str,
|
||||
on_value: Any,
|
||||
name: str,
|
||||
icon: str,
|
||||
device_class: str,
|
||||
inverted: bool = False,
|
||||
) -> None:
|
||||
def __init__(self, coordinator: ToonDataUpdateCoordinator, *, key: str) -> None:
|
||||
"""Initialize the Toon sensor."""
|
||||
self._state = inverted
|
||||
self._device_class = device_class
|
||||
self.section = section
|
||||
self.measurement = measurement
|
||||
self.on_value = on_value
|
||||
self.inverted = inverted
|
||||
self.key = key
|
||||
|
||||
super().__init__(toon, name, icon)
|
||||
super().__init__(
|
||||
coordinator,
|
||||
enabled_default=BINARY_SENSOR_ENTITIES[key][ATTR_DEFAULT_ENABLED],
|
||||
icon=BINARY_SENSOR_ENTITIES[key][ATTR_ICON],
|
||||
name=BINARY_SENSOR_ENTITIES[key][ATTR_NAME],
|
||||
)
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Return the unique ID for this binary sensor."""
|
||||
return "_".join(
|
||||
[
|
||||
DOMAIN,
|
||||
self.toon.agreement.id,
|
||||
"binary_sensor",
|
||||
self.section,
|
||||
self.measurement,
|
||||
str(self.on_value),
|
||||
]
|
||||
)
|
||||
agreement_id = self.coordinator.data.agreement.agreement_id
|
||||
# This unique ID is a bit ugly and contains unneeded information.
|
||||
# It is here for legacy / backward compatible reasons.
|
||||
return f"{DOMAIN}_{agreement_id}_binary_sensor_{self.key}"
|
||||
|
||||
@property
|
||||
def device_class(self) -> str:
|
||||
"""Return the device class."""
|
||||
return self._device_class
|
||||
return BINARY_SENSOR_ENTITIES[self.key][ATTR_DEVICE_CLASS]
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
def is_on(self) -> Optional[bool]:
|
||||
"""Return the status of the binary sensor."""
|
||||
if self.on_value is not None:
|
||||
value = self._state == self.on_value
|
||||
elif self._state is None:
|
||||
value = False
|
||||
else:
|
||||
value = bool(max(0, int(self._state)))
|
||||
section = getattr(
|
||||
self.coordinator.data, BINARY_SENSOR_ENTITIES[self.key][ATTR_SECTION]
|
||||
)
|
||||
value = getattr(section, BINARY_SENSOR_ENTITIES[self.key][ATTR_MEASUREMENT])
|
||||
|
||||
if self.inverted:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if BINARY_SENSOR_ENTITIES[self.key][ATTR_INVERTED]:
|
||||
return not value
|
||||
|
||||
return value
|
||||
|
||||
def update(self) -> None:
|
||||
"""Get the latest data from the binary sensor."""
|
||||
section = getattr(self.toon, self.section)
|
||||
self._state = getattr(section, self.measurement)
|
||||
|
||||
|
||||
class ToonBoilerBinarySensor(ToonBinarySensor, ToonBoilerDeviceEntity):
|
||||
"""Defines a Boiler binary sensor."""
|
||||
|
|
|
@ -1,8 +1,14 @@
|
|||
"""Support for Toon thermostat."""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from toonapi import (
|
||||
ACTIVE_STATE_AWAY,
|
||||
ACTIVE_STATE_COMFORT,
|
||||
ACTIVE_STATE_HOME,
|
||||
ACTIVE_STATE_SLEEP,
|
||||
)
|
||||
|
||||
from homeassistant.components.climate import ClimateEntity
|
||||
from homeassistant.components.climate.const import (
|
||||
CURRENT_HVAC_HEAT,
|
||||
|
@ -19,56 +25,38 @@ from homeassistant.config_entries import ConfigEntry
|
|||
from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
|
||||
from . import ToonData, ToonDisplayDeviceEntity
|
||||
from .const import (
|
||||
DATA_TOON,
|
||||
DATA_TOON_CLIENT,
|
||||
DEFAULT_MAX_TEMP,
|
||||
DEFAULT_MIN_TEMP,
|
||||
DOMAIN,
|
||||
)
|
||||
from .const import DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, DOMAIN
|
||||
from .helpers import toon_exception_handler
|
||||
from .models import ToonDisplayDeviceEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE
|
||||
SUPPORT_PRESET = [PRESET_AWAY, PRESET_COMFORT, PRESET_HOME, PRESET_SLEEP]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistantType, entry: ConfigEntry, async_add_entities
|
||||
) -> None:
|
||||
"""Set up a Toon binary sensors based on a config entry."""
|
||||
toon_client = hass.data[DATA_TOON_CLIENT][entry.entry_id]
|
||||
toon_data = hass.data[DATA_TOON][entry.entry_id]
|
||||
async_add_entities([ToonThermostatDevice(toon_client, toon_data)], True)
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_entities(
|
||||
[ToonThermostatDevice(coordinator, name="Thermostat", icon="mdi:thermostat")]
|
||||
)
|
||||
|
||||
|
||||
class ToonThermostatDevice(ToonDisplayDeviceEntity, ClimateEntity):
|
||||
"""Representation of a Toon climate device."""
|
||||
|
||||
def __init__(self, toon_client, toon_data: ToonData) -> None:
|
||||
"""Initialize the Toon climate device."""
|
||||
self._client = toon_client
|
||||
|
||||
self._current_temperature = None
|
||||
self._target_temperature = None
|
||||
self._heating = False
|
||||
self._next_target_temperature = None
|
||||
self._preset = None
|
||||
|
||||
self._heating_type = None
|
||||
|
||||
super().__init__(toon_data, "Toon Thermostat", "mdi:thermostat")
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Return the unique ID for this thermostat."""
|
||||
return "_".join([DOMAIN, self.toon.agreement.id, "climate"])
|
||||
agreement_id = self.coordinator.data.agreement.agreement_id
|
||||
# This unique ID is a bit ugly and contains unneeded information.
|
||||
# It is here for lecagy / backward compatible reasons.
|
||||
return f"{DOMAIN}_{agreement_id}_climate"
|
||||
|
||||
@property
|
||||
def supported_features(self) -> int:
|
||||
"""Return the list of supported features."""
|
||||
return SUPPORT_FLAGS
|
||||
return SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> str:
|
||||
|
@ -83,7 +71,7 @@ class ToonThermostatDevice(ToonDisplayDeviceEntity, ClimateEntity):
|
|||
@property
|
||||
def hvac_action(self) -> Optional[str]:
|
||||
"""Return the current running hvac operation."""
|
||||
if self._heating:
|
||||
if self.coordinator.data.thermostat.heating:
|
||||
return CURRENT_HVAC_HEAT
|
||||
return CURRENT_HVAC_IDLE
|
||||
|
||||
|
@ -95,24 +83,28 @@ class ToonThermostatDevice(ToonDisplayDeviceEntity, ClimateEntity):
|
|||
@property
|
||||
def preset_mode(self) -> Optional[str]:
|
||||
"""Return the current preset mode, e.g., home, away, temp."""
|
||||
if self._preset is not None:
|
||||
return self._preset.lower()
|
||||
return None
|
||||
mapping = {
|
||||
ACTIVE_STATE_AWAY: PRESET_AWAY,
|
||||
ACTIVE_STATE_COMFORT: PRESET_COMFORT,
|
||||
ACTIVE_STATE_HOME: PRESET_HOME,
|
||||
ACTIVE_STATE_SLEEP: PRESET_SLEEP,
|
||||
}
|
||||
return mapping.get(self.coordinator.data.thermostat.active_state)
|
||||
|
||||
@property
|
||||
def preset_modes(self) -> List[str]:
|
||||
"""Return a list of available preset modes."""
|
||||
return SUPPORT_PRESET
|
||||
return [PRESET_AWAY, PRESET_COMFORT, PRESET_HOME, PRESET_SLEEP]
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> Optional[float]:
|
||||
"""Return the current temperature."""
|
||||
return self._current_temperature
|
||||
return self.coordinator.data.thermostat.current_display_temperature
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> Optional[float]:
|
||||
"""Return the temperature we try to reach."""
|
||||
return self._target_temperature
|
||||
return self.coordinator.data.thermostat.current_setpoint
|
||||
|
||||
@property
|
||||
def min_temp(self) -> float:
|
||||
|
@ -127,30 +119,27 @@ class ToonThermostatDevice(ToonDisplayDeviceEntity, ClimateEntity):
|
|||
@property
|
||||
def device_state_attributes(self) -> Dict[str, Any]:
|
||||
"""Return the current state of the burner."""
|
||||
return {"heating_type": self._heating_type}
|
||||
return {"heating_type": self.coordinator.data.agreement.heating_type}
|
||||
|
||||
def set_temperature(self, **kwargs) -> None:
|
||||
@toon_exception_handler
|
||||
async def async_set_temperature(self, **kwargs) -> None:
|
||||
"""Change the setpoint of the thermostat."""
|
||||
temperature = kwargs.get(ATTR_TEMPERATURE)
|
||||
self._client.thermostat = self._target_temperature = temperature
|
||||
self.schedule_update_ha_state()
|
||||
await self.coordinator.toon.set_current_setpoint(temperature)
|
||||
|
||||
def set_preset_mode(self, preset_mode: str) -> None:
|
||||
@toon_exception_handler
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set new preset mode."""
|
||||
self._client.thermostat_state = self._preset = preset_mode
|
||||
self.schedule_update_ha_state()
|
||||
mapping = {
|
||||
PRESET_AWAY: ACTIVE_STATE_AWAY,
|
||||
PRESET_COMFORT: ACTIVE_STATE_COMFORT,
|
||||
PRESET_HOME: ACTIVE_STATE_HOME,
|
||||
PRESET_SLEEP: ACTIVE_STATE_SLEEP,
|
||||
}
|
||||
if preset_mode in mapping:
|
||||
await self.coordinator.toon.set_active_state(mapping[preset_mode])
|
||||
|
||||
def set_hvac_mode(self, hvac_mode: str) -> None:
|
||||
"""Set new target hvac mode."""
|
||||
|
||||
def update(self) -> None:
|
||||
"""Update local state."""
|
||||
if self.toon.thermostat_state is None:
|
||||
self._preset = None
|
||||
else:
|
||||
self._preset = self.toon.thermostat_state.name
|
||||
|
||||
self._current_temperature = self.toon.temperature
|
||||
self._target_temperature = self.toon.thermostat
|
||||
self._heating_type = self.toon.agreement.heating_type
|
||||
self._heating = self.toon.thermostat_info.burner_info == 1
|
||||
# Intentionally left empty
|
||||
# The HAVC mode is always HEAT
|
||||
|
|
|
@ -1,166 +1,103 @@
|
|||
"""Config flow to configure the Toon component."""
|
||||
from collections import OrderedDict
|
||||
from functools import partial
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from toonapilib import Toon
|
||||
from toonapilib.toonapilibexceptions import (
|
||||
AgreementsRetrievalError,
|
||||
InvalidConsumerKey,
|
||||
InvalidConsumerSecret,
|
||||
InvalidCredentials,
|
||||
)
|
||||
from toonapi import Agreement, Toon, ToonError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import (
|
||||
CONF_CLIENT_ID,
|
||||
CONF_CLIENT_SECRET,
|
||||
CONF_PASSWORD,
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
|
||||
|
||||
from .const import CONF_DISPLAY, CONF_TENANT, DATA_TOON_CONFIG, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
from .const import CONF_AGREEMENT, CONF_AGREEMENT_ID, CONF_MIGRATE, DOMAIN
|
||||
|
||||
|
||||
@callback
|
||||
def configured_displays(hass):
|
||||
"""Return a set of configured Toon displays."""
|
||||
return {
|
||||
entry.data[CONF_DISPLAY] for entry in hass.config_entries.async_entries(DOMAIN)
|
||||
}
|
||||
|
||||
|
||||
@config_entries.HANDLERS.register(DOMAIN)
|
||||
class ToonFlowHandler(config_entries.ConfigFlow):
|
||||
class ToonFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
"""Handle a Toon config flow."""
|
||||
|
||||
VERSION = 1
|
||||
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
|
||||
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH
|
||||
DOMAIN = DOMAIN
|
||||
VERSION = 2
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the Toon flow."""
|
||||
self.displays = None
|
||||
self.username = None
|
||||
self.password = None
|
||||
self.tenant = None
|
||||
agreements: Optional[List[Agreement]] = None
|
||||
data: Optional[Dict[str, Any]] = None
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
"""Handle a flow initiated by the user."""
|
||||
app = self.hass.data.get(DATA_TOON_CONFIG, {})
|
||||
@property
|
||||
def logger(self) -> logging.Logger:
|
||||
"""Return logger."""
|
||||
return logging.getLogger(__name__)
|
||||
|
||||
if not app:
|
||||
return self.async_abort(reason="no_app")
|
||||
async def async_oauth_create_entry(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Test connection and load up agreements."""
|
||||
self.data = data
|
||||
|
||||
return await self.async_step_authenticate(user_input)
|
||||
|
||||
async def _show_authenticaticate_form(self, errors=None):
|
||||
"""Show the authentication form to the user."""
|
||||
fields = OrderedDict()
|
||||
fields[vol.Required(CONF_USERNAME)] = str
|
||||
fields[vol.Required(CONF_PASSWORD)] = str
|
||||
fields[vol.Optional(CONF_TENANT)] = vol.In(["eneco", "electrabel", "viesgo"])
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="authenticate",
|
||||
data_schema=vol.Schema(fields),
|
||||
errors=errors if errors else {},
|
||||
toon = Toon(
|
||||
token=self.data["token"]["access_token"],
|
||||
session=async_get_clientsession(self.hass),
|
||||
)
|
||||
|
||||
async def async_step_authenticate(self, user_input=None):
|
||||
"""Attempt to authenticate with the Toon account."""
|
||||
|
||||
if user_input is None:
|
||||
return await self._show_authenticaticate_form()
|
||||
|
||||
app = self.hass.data.get(DATA_TOON_CONFIG, {})
|
||||
try:
|
||||
toon = await self.hass.async_add_executor_job(
|
||||
partial(
|
||||
Toon,
|
||||
user_input[CONF_USERNAME],
|
||||
user_input[CONF_PASSWORD],
|
||||
app[CONF_CLIENT_ID],
|
||||
app[CONF_CLIENT_SECRET],
|
||||
tenant_id=user_input[CONF_TENANT],
|
||||
)
|
||||
)
|
||||
self.agreements = await toon.agreements()
|
||||
except ToonError:
|
||||
return self.async_abort(reason="connection_error")
|
||||
|
||||
displays = toon.display_names
|
||||
|
||||
except InvalidConsumerKey:
|
||||
return self.async_abort(reason=CONF_CLIENT_ID)
|
||||
|
||||
except InvalidConsumerSecret:
|
||||
return self.async_abort(reason=CONF_CLIENT_SECRET)
|
||||
|
||||
except InvalidCredentials:
|
||||
return await self._show_authenticaticate_form({"base": "credentials"})
|
||||
|
||||
except AgreementsRetrievalError:
|
||||
if not self.agreements:
|
||||
return self.async_abort(reason="no_agreements")
|
||||
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Unexpected error while authenticating")
|
||||
return self.async_abort(reason="unknown_auth_fail")
|
||||
return await self.async_step_agreement()
|
||||
|
||||
self.displays = displays
|
||||
self.username = user_input[CONF_USERNAME]
|
||||
self.password = user_input[CONF_PASSWORD]
|
||||
self.tenant = user_input[CONF_TENANT]
|
||||
async def async_step_import(
|
||||
self, config: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Start a configuration flow based on imported data.
|
||||
|
||||
return await self.async_step_display()
|
||||
This step is merely here to trigger "discovery" when the `toon`
|
||||
integration is listed in the user configuration, or when migrating from
|
||||
the version 1 schema.
|
||||
"""
|
||||
|
||||
async def _show_display_form(self, errors=None):
|
||||
"""Show the select display form to the user."""
|
||||
fields = OrderedDict()
|
||||
fields[vol.Required(CONF_DISPLAY)] = vol.In(self.displays)
|
||||
if config is not None and CONF_MIGRATE in config:
|
||||
# pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
|
||||
self.context.update({CONF_MIGRATE: config[CONF_MIGRATE]})
|
||||
else:
|
||||
await self._async_handle_discovery_without_unique_id()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="display",
|
||||
data_schema=vol.Schema(fields),
|
||||
errors=errors if errors else {},
|
||||
)
|
||||
return await self.async_step_user()
|
||||
|
||||
async def async_step_display(self, user_input=None):
|
||||
"""Select Toon display to add."""
|
||||
async def async_step_agreement(
|
||||
self, user_input: Dict[str, Any] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Select Toon agreement to add."""
|
||||
if len(self.agreements) == 1:
|
||||
return await self._create_entry(self.agreements[0])
|
||||
|
||||
if not self.displays:
|
||||
return self.async_abort(reason="no_displays")
|
||||
agreements_list = [
|
||||
f"{agreement.street} {agreement.house_number}, {agreement.city}"
|
||||
for agreement in self.agreements
|
||||
]
|
||||
|
||||
if user_input is None:
|
||||
return await self._show_display_form()
|
||||
|
||||
if user_input[CONF_DISPLAY] in configured_displays(self.hass):
|
||||
return await self._show_display_form({"base": "display_exists"})
|
||||
|
||||
app = self.hass.data.get(DATA_TOON_CONFIG, {})
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
partial(
|
||||
Toon,
|
||||
self.username,
|
||||
self.password,
|
||||
app[CONF_CLIENT_ID],
|
||||
app[CONF_CLIENT_SECRET],
|
||||
tenant_id=self.tenant,
|
||||
display_common_name=user_input[CONF_DISPLAY],
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="agreement",
|
||||
data_schema=vol.Schema(
|
||||
{vol.Required(CONF_AGREEMENT): vol.In(agreements_list)}
|
||||
),
|
||||
)
|
||||
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Unexpected error while authenticating")
|
||||
return self.async_abort(reason="unknown_auth_fail")
|
||||
agreement_index = agreements_list.index(user_input[CONF_AGREEMENT])
|
||||
return await self._create_entry(self.agreements[agreement_index])
|
||||
|
||||
async def _create_entry(self, agreement: Agreement) -> Dict[str, Any]:
|
||||
if ( # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
|
||||
CONF_MIGRATE in self.context
|
||||
):
|
||||
# pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
|
||||
await self.hass.config_entries.async_remove(self.context[CONF_MIGRATE])
|
||||
|
||||
await self.async_set_unique_id(agreement.agreement_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
self.data[CONF_AGREEMENT_ID] = agreement.agreement_id
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_DISPLAY],
|
||||
data={
|
||||
CONF_USERNAME: self.username,
|
||||
CONF_PASSWORD: self.password,
|
||||
CONF_TENANT: self.tenant,
|
||||
CONF_DISPLAY: user_input[CONF_DISPLAY],
|
||||
},
|
||||
title=f"{agreement.street} {agreement.house_number}, {agreement.city}",
|
||||
data=self.data,
|
||||
)
|
||||
|
|
|
@ -1,15 +1,27 @@
|
|||
"""Constants for the Toon integration."""
|
||||
from datetime import timedelta
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
DEVICE_CLASS_CONNECTIVITY,
|
||||
DEVICE_CLASS_PROBLEM,
|
||||
)
|
||||
from homeassistant.components.sensor import DEVICE_CLASS_POWER
|
||||
from homeassistant.const import (
|
||||
ATTR_DEVICE_CLASS,
|
||||
ATTR_ICON,
|
||||
ATTR_NAME,
|
||||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
ENERGY_KILO_WATT_HOUR,
|
||||
POWER_WATT,
|
||||
UNIT_PERCENTAGE,
|
||||
)
|
||||
|
||||
DOMAIN = "toon"
|
||||
|
||||
DATA_TOON = "toon"
|
||||
DATA_TOON_CLIENT = "toon_client"
|
||||
DATA_TOON_CONFIG = "toon_config"
|
||||
DATA_TOON_UPDATED = "toon_updated"
|
||||
|
||||
CONF_DISPLAY = "display"
|
||||
CONF_TENANT = "tenant"
|
||||
CONF_AGREEMENT = "agreement"
|
||||
CONF_AGREEMENT_ID = "agreement_id"
|
||||
CONF_CLOUDHOOK_URL = "cloudhook_url"
|
||||
CONF_MIGRATE = "migrate"
|
||||
|
||||
DEFAULT_SCAN_INTERVAL = timedelta(seconds=300)
|
||||
DEFAULT_MAX_TEMP = 30.0
|
||||
|
@ -18,3 +30,321 @@ DEFAULT_MIN_TEMP = 6.0
|
|||
CURRENCY_EUR = "EUR"
|
||||
VOLUME_CM3 = "CM3"
|
||||
VOLUME_M3 = "M3"
|
||||
|
||||
ATTR_DEFAULT_ENABLED = "default_enabled"
|
||||
ATTR_INVERTED = "inverted"
|
||||
ATTR_MEASUREMENT = "measurement"
|
||||
ATTR_SECTION = "section"
|
||||
|
||||
BINARY_SENSOR_ENTITIES = {
|
||||
"thermostat_info_boiler_connected_None": {
|
||||
ATTR_NAME: "Boiler Module Connection",
|
||||
ATTR_SECTION: "thermostat",
|
||||
ATTR_MEASUREMENT: "boiler_module_connected",
|
||||
ATTR_INVERTED: False,
|
||||
ATTR_DEVICE_CLASS: DEVICE_CLASS_CONNECTIVITY,
|
||||
ATTR_ICON: "mdi:check-network-outline",
|
||||
ATTR_DEFAULT_ENABLED: False,
|
||||
},
|
||||
"thermostat_info_burner_info_1": {
|
||||
ATTR_NAME: "Boiler Heating",
|
||||
ATTR_SECTION: "thermostat",
|
||||
ATTR_MEASUREMENT: "heating",
|
||||
ATTR_INVERTED: False,
|
||||
ATTR_DEVICE_CLASS: None,
|
||||
ATTR_ICON: "mdi:fire",
|
||||
ATTR_DEFAULT_ENABLED: False,
|
||||
},
|
||||
"thermostat_info_burner_info_2": {
|
||||
ATTR_NAME: "Hot Tap Water",
|
||||
ATTR_SECTION: "thermostat",
|
||||
ATTR_MEASUREMENT: "hot_tapwater",
|
||||
ATTR_INVERTED: False,
|
||||
ATTR_DEVICE_CLASS: None,
|
||||
ATTR_ICON: "mdi:water-pump",
|
||||
ATTR_DEFAULT_ENABLED: True,
|
||||
},
|
||||
"thermostat_info_burner_info_3": {
|
||||
ATTR_NAME: "Boiler Preheating",
|
||||
ATTR_SECTION: "thermostat",
|
||||
ATTR_MEASUREMENT: "pre_heating",
|
||||
ATTR_INVERTED: False,
|
||||
ATTR_DEVICE_CLASS: None,
|
||||
ATTR_ICON: "mdi:fire",
|
||||
ATTR_DEFAULT_ENABLED: False,
|
||||
},
|
||||
"thermostat_info_burner_info_None": {
|
||||
ATTR_NAME: "Boiler Burner",
|
||||
ATTR_SECTION: "thermostat",
|
||||
ATTR_MEASUREMENT: "burner",
|
||||
ATTR_INVERTED: False,
|
||||
ATTR_DEVICE_CLASS: None,
|
||||
ATTR_ICON: "mdi:fire",
|
||||
ATTR_DEFAULT_ENABLED: True,
|
||||
},
|
||||
"thermostat_info_error_found_255": {
|
||||
ATTR_NAME: "Boiler Status",
|
||||
ATTR_SECTION: "thermostat",
|
||||
ATTR_MEASUREMENT: "error_found",
|
||||
ATTR_INVERTED: False,
|
||||
ATTR_DEVICE_CLASS: DEVICE_CLASS_PROBLEM,
|
||||
ATTR_ICON: "mdi:alert",
|
||||
ATTR_DEFAULT_ENABLED: True,
|
||||
},
|
||||
"thermostat_info_ot_communication_error_0": {
|
||||
ATTR_NAME: "OpenTherm Connection",
|
||||
ATTR_SECTION: "thermostat",
|
||||
ATTR_MEASUREMENT: "opentherm_communication_error",
|
||||
ATTR_INVERTED: False,
|
||||
ATTR_DEVICE_CLASS: DEVICE_CLASS_PROBLEM,
|
||||
ATTR_ICON: "mdi:check-network-outline",
|
||||
ATTR_DEFAULT_ENABLED: False,
|
||||
},
|
||||
"thermostat_program_overridden": {
|
||||
ATTR_NAME: "Thermostat Program Override",
|
||||
ATTR_SECTION: "thermostat",
|
||||
ATTR_MEASUREMENT: "program_overridden",
|
||||
ATTR_INVERTED: False,
|
||||
ATTR_DEVICE_CLASS: None,
|
||||
ATTR_ICON: "mdi:gesture-tap",
|
||||
ATTR_DEFAULT_ENABLED: True,
|
||||
},
|
||||
}
|
||||
|
||||
SENSOR_ENTITIES = {
|
||||
"gas_average": {
|
||||
ATTR_NAME: "Average Gas Usage",
|
||||
ATTR_SECTION: "gas_usage",
|
||||
ATTR_MEASUREMENT: "average",
|
||||
ATTR_UNIT_OF_MEASUREMENT: VOLUME_CM3,
|
||||
ATTR_DEVICE_CLASS: None,
|
||||
ATTR_ICON: "mdi:gas-cylinder",
|
||||
ATTR_DEFAULT_ENABLED: True,
|
||||
},
|
||||
"gas_average_daily": {
|
||||
ATTR_NAME: "Average Daily Gas Usage",
|
||||
ATTR_SECTION: "gas_usage",
|
||||
ATTR_MEASUREMENT: "day_average",
|
||||
ATTR_UNIT_OF_MEASUREMENT: VOLUME_M3,
|
||||
ATTR_DEVICE_CLASS: None,
|
||||
ATTR_ICON: "mdi:gas-cylinder",
|
||||
ATTR_DEFAULT_ENABLED: False,
|
||||
},
|
||||
"gas_daily_usage": {
|
||||
ATTR_NAME: "Gas Usage Today",
|
||||
ATTR_SECTION: "gas_usage",
|
||||
ATTR_MEASUREMENT: "day_usage",
|
||||
ATTR_UNIT_OF_MEASUREMENT: VOLUME_M3,
|
||||
ATTR_DEVICE_CLASS: None,
|
||||
ATTR_ICON: "mdi:gas-cylinder",
|
||||
ATTR_DEFAULT_ENABLED: True,
|
||||
},
|
||||
"gas_daily_cost": {
|
||||
ATTR_NAME: "Gas Cost Today",
|
||||
ATTR_SECTION: "gas_usage",
|
||||
ATTR_MEASUREMENT: "day_cost",
|
||||
ATTR_UNIT_OF_MEASUREMENT: CURRENCY_EUR,
|
||||
ATTR_DEVICE_CLASS: None,
|
||||
ATTR_ICON: "mdi:gas-cylinder",
|
||||
ATTR_DEFAULT_ENABLED: True,
|
||||
},
|
||||
"gas_meter_reading": {
|
||||
ATTR_NAME: "Gas Meter",
|
||||
ATTR_SECTION: "gas_usage",
|
||||
ATTR_MEASUREMENT: "meter",
|
||||
ATTR_UNIT_OF_MEASUREMENT: VOLUME_M3,
|
||||
ATTR_DEVICE_CLASS: None,
|
||||
ATTR_ICON: "mdi:gas-cylinder",
|
||||
ATTR_DEFAULT_ENABLED: False,
|
||||
},
|
||||
"gas_value": {
|
||||
ATTR_NAME: "Current Gas Usage",
|
||||
ATTR_SECTION: "gas_usage",
|
||||
ATTR_MEASUREMENT: "current",
|
||||
ATTR_UNIT_OF_MEASUREMENT: VOLUME_CM3,
|
||||
ATTR_DEVICE_CLASS: None,
|
||||
ATTR_ICON: "mdi:gas-cylinder",
|
||||
ATTR_DEFAULT_ENABLED: True,
|
||||
},
|
||||
"power_average": {
|
||||
ATTR_NAME: "Average Power Usage",
|
||||
ATTR_SECTION: "power_usage",
|
||||
ATTR_MEASUREMENT: "average",
|
||||
ATTR_UNIT_OF_MEASUREMENT: POWER_WATT,
|
||||
ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER,
|
||||
ATTR_ICON: "mdi:power-plug",
|
||||
ATTR_DEFAULT_ENABLED: False,
|
||||
},
|
||||
"power_average_daily": {
|
||||
ATTR_NAME: "Average Daily Energy Usage",
|
||||
ATTR_SECTION: "power_usage",
|
||||
ATTR_MEASUREMENT: "day_average",
|
||||
ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR,
|
||||
ATTR_DEVICE_CLASS: None,
|
||||
ATTR_ICON: "mdi:power-plug",
|
||||
ATTR_DEFAULT_ENABLED: False,
|
||||
},
|
||||
"power_daily_cost": {
|
||||
ATTR_NAME: "Energy Cost Today",
|
||||
ATTR_SECTION: "power_usage",
|
||||
ATTR_MEASUREMENT: "day_cost",
|
||||
ATTR_UNIT_OF_MEASUREMENT: CURRENCY_EUR,
|
||||
ATTR_DEVICE_CLASS: None,
|
||||
ATTR_ICON: "mdi:power-plug",
|
||||
ATTR_DEFAULT_ENABLED: True,
|
||||
},
|
||||
"power_daily_value": {
|
||||
ATTR_NAME: "Energy Usage Today",
|
||||
ATTR_SECTION: "power_usage",
|
||||
ATTR_MEASUREMENT: "day_usage",
|
||||
ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR,
|
||||
ATTR_DEVICE_CLASS: None,
|
||||
ATTR_ICON: "mdi:power-plug",
|
||||
ATTR_DEFAULT_ENABLED: True,
|
||||
},
|
||||
"power_meter_reading": {
|
||||
ATTR_NAME: "Electricity Meter Feed IN Tariff 1",
|
||||
ATTR_SECTION: "power_usage",
|
||||
ATTR_MEASUREMENT: "meter_high",
|
||||
ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR,
|
||||
ATTR_DEVICE_CLASS: None,
|
||||
ATTR_ICON: "mdi:power-plug",
|
||||
ATTR_DEFAULT_ENABLED: False,
|
||||
},
|
||||
"power_meter_reading_low": {
|
||||
ATTR_NAME: "Electricity Meter Feed IN Tariff 2",
|
||||
ATTR_SECTION: "power_usage",
|
||||
ATTR_MEASUREMENT: "meter_high",
|
||||
ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR,
|
||||
ATTR_DEVICE_CLASS: None,
|
||||
ATTR_ICON: "mdi:power-plug",
|
||||
ATTR_DEFAULT_ENABLED: False,
|
||||
},
|
||||
"power_value": {
|
||||
ATTR_NAME: "Current Power Usage",
|
||||
ATTR_SECTION: "power_usage",
|
||||
ATTR_MEASUREMENT: "current",
|
||||
ATTR_UNIT_OF_MEASUREMENT: POWER_WATT,
|
||||
ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER,
|
||||
ATTR_ICON: "mdi:power-plug",
|
||||
ATTR_DEFAULT_ENABLED: True,
|
||||
},
|
||||
"solar_meter_reading_produced": {
|
||||
ATTR_NAME: "Electricity Meter Feed OUT Tariff 1",
|
||||
ATTR_SECTION: "power_usage",
|
||||
ATTR_MEASUREMENT: "meter_produced_high",
|
||||
ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR,
|
||||
ATTR_DEVICE_CLASS: None,
|
||||
ATTR_ICON: "mdi:power-plug",
|
||||
ATTR_DEFAULT_ENABLED: False,
|
||||
},
|
||||
"solar_meter_reading_low_produced": {
|
||||
ATTR_NAME: "Electricity Meter Feed OUT Tariff 2",
|
||||
ATTR_SECTION: "power_usage",
|
||||
ATTR_MEASUREMENT: "meter_produced_low",
|
||||
ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR,
|
||||
ATTR_DEVICE_CLASS: None,
|
||||
ATTR_ICON: "mdi:power-plug",
|
||||
ATTR_DEFAULT_ENABLED: False,
|
||||
},
|
||||
"solar_value": {
|
||||
ATTR_NAME: "Current Solar Power Production",
|
||||
ATTR_SECTION: "power_usage",
|
||||
ATTR_MEASUREMENT: "current_solar",
|
||||
ATTR_UNIT_OF_MEASUREMENT: POWER_WATT,
|
||||
ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER,
|
||||
ATTR_ICON: "mdi:solar-power",
|
||||
ATTR_DEFAULT_ENABLED: True,
|
||||
},
|
||||
"solar_maximum": {
|
||||
ATTR_NAME: "Max Solar Power Production Today",
|
||||
ATTR_SECTION: "power_usage",
|
||||
ATTR_MEASUREMENT: "day_max_solar",
|
||||
ATTR_UNIT_OF_MEASUREMENT: POWER_WATT,
|
||||
ATTR_DEVICE_CLASS: None,
|
||||
ATTR_ICON: "mdi:solar-power",
|
||||
ATTR_DEFAULT_ENABLED: True,
|
||||
},
|
||||
"solar_produced": {
|
||||
ATTR_NAME: "Solar Power Production to Grid",
|
||||
ATTR_SECTION: "power_usage",
|
||||
ATTR_MEASUREMENT: "current_produced",
|
||||
ATTR_UNIT_OF_MEASUREMENT: POWER_WATT,
|
||||
ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER,
|
||||
ATTR_ICON: "mdi:solar-power",
|
||||
ATTR_DEFAULT_ENABLED: True,
|
||||
},
|
||||
"power_usage_day_produced_solar": {
|
||||
ATTR_NAME: "Solar Energy Produced Today",
|
||||
ATTR_SECTION: "power_usage",
|
||||
ATTR_MEASUREMENT: "day_produced_solar",
|
||||
ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR,
|
||||
ATTR_DEVICE_CLASS: None,
|
||||
ATTR_ICON: "mdi:solar-power",
|
||||
ATTR_DEFAULT_ENABLED: True,
|
||||
},
|
||||
"power_usage_day_to_grid_usage": {
|
||||
ATTR_NAME: "Energy Produced To Grid Today",
|
||||
ATTR_SECTION: "power_usage",
|
||||
ATTR_MEASUREMENT: "day_to_grid_usage",
|
||||
ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR,
|
||||
ATTR_DEVICE_CLASS: None,
|
||||
ATTR_ICON: "mdi:solar-power",
|
||||
ATTR_DEFAULT_ENABLED: False,
|
||||
},
|
||||
"power_usage_day_from_grid_usage": {
|
||||
ATTR_NAME: "Energy Usage From Grid Today",
|
||||
ATTR_SECTION: "power_usage",
|
||||
ATTR_MEASUREMENT: "day_from_grid_usage",
|
||||
ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR,
|
||||
ATTR_DEVICE_CLASS: None,
|
||||
ATTR_ICON: "mdi:power-plug",
|
||||
ATTR_DEFAULT_ENABLED: False,
|
||||
},
|
||||
"solar_average_produced": {
|
||||
ATTR_NAME: "Average Solar Power Production to Grid",
|
||||
ATTR_SECTION: "power_usage",
|
||||
ATTR_MEASUREMENT: "average_produced",
|
||||
ATTR_UNIT_OF_MEASUREMENT: POWER_WATT,
|
||||
ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER,
|
||||
ATTR_ICON: "mdi:solar-power",
|
||||
ATTR_DEFAULT_ENABLED: False,
|
||||
},
|
||||
"thermostat_info_current_modulation_level": {
|
||||
ATTR_NAME: "Boiler Modulation Level",
|
||||
ATTR_SECTION: "thermostat",
|
||||
ATTR_MEASUREMENT: "current_modulation_level",
|
||||
ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE,
|
||||
ATTR_DEVICE_CLASS: None,
|
||||
ATTR_ICON: "mdi:percent",
|
||||
ATTR_DEFAULT_ENABLED: False,
|
||||
},
|
||||
"power_usage_current_covered_by_solar": {
|
||||
ATTR_NAME: "Current Power Usage Covered By Solar",
|
||||
ATTR_SECTION: "power_usage",
|
||||
ATTR_MEASUREMENT: "current_covered_by_solar",
|
||||
ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE,
|
||||
ATTR_DEVICE_CLASS: None,
|
||||
ATTR_ICON: "mdi:solar-power",
|
||||
ATTR_DEFAULT_ENABLED: True,
|
||||
},
|
||||
}
|
||||
|
||||
SWITCH_ENTITIES = {
|
||||
"thermostat_holiday_mode": {
|
||||
ATTR_NAME: "Holiday Mode",
|
||||
ATTR_SECTION: "thermostat",
|
||||
ATTR_MEASUREMENT: "holiday_mode",
|
||||
ATTR_INVERTED: False,
|
||||
ATTR_ICON: "mdi:airport",
|
||||
ATTR_DEFAULT_ENABLED: True,
|
||||
},
|
||||
"thermostat_program": {
|
||||
ATTR_NAME: "Thermostat Program",
|
||||
ATTR_SECTION: "thermostat",
|
||||
ATTR_MEASUREMENT: "program",
|
||||
ATTR_INVERTED: False,
|
||||
ATTR_ICON: "mdi:calendar-clock",
|
||||
ATTR_DEFAULT_ENABLED: True,
|
||||
},
|
||||
}
|
||||
|
|
|
@ -0,0 +1,141 @@
|
|||
"""Provides the Toon DataUpdateCoordinator."""
|
||||
import logging
|
||||
import secrets
|
||||
from typing import Optional
|
||||
|
||||
from toonapi import Status, Toon, ToonError
|
||||
|
||||
from homeassistant.components.webhook import (
|
||||
async_register as webhook_register,
|
||||
async_unregister as webhook_unregister,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_WEBHOOK_ID, EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import CONF_CLOUDHOOK_URL, DEFAULT_SCAN_INTERVAL, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ToonDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
"""Class to manage fetching WLED data from single endpoint."""
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, *, entry: ConfigEntry, session: OAuth2Session
|
||||
):
|
||||
"""Initialize global Toon data updater."""
|
||||
self.session = session
|
||||
self.entry = entry
|
||||
|
||||
async def async_token_refresh() -> str:
|
||||
await session.async_ensure_token_valid()
|
||||
return session.token["access_token"]
|
||||
|
||||
self.toon = Toon(
|
||||
token=session.token["access_token"],
|
||||
session=async_get_clientsession(hass),
|
||||
token_refresh_method=async_token_refresh,
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
hass, _LOGGER, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL
|
||||
)
|
||||
|
||||
def update_listeners(self) -> None:
|
||||
"""Call update on all listeners."""
|
||||
for update_callback in self._listeners:
|
||||
update_callback()
|
||||
|
||||
async def register_webhook(self, event: Optional[Event] = None) -> None:
|
||||
"""Register a webhook with Toon to get live updates."""
|
||||
if CONF_WEBHOOK_ID not in self.entry.data:
|
||||
data = {**self.entry.data, CONF_WEBHOOK_ID: secrets.token_hex()}
|
||||
self.hass.config_entries.async_update_entry(self.entry, data=data)
|
||||
|
||||
if self.hass.components.cloud.async_active_subscription():
|
||||
|
||||
if CONF_CLOUDHOOK_URL not in self.entry.data:
|
||||
webhook_url = await self.hass.components.cloud.async_create_cloudhook(
|
||||
self.entry.data[CONF_WEBHOOK_ID]
|
||||
)
|
||||
data = {**self.entry.data, CONF_CLOUDHOOK_URL: webhook_url}
|
||||
self.hass.config_entries.async_update_entry(self.entry, data=data)
|
||||
else:
|
||||
webhook_url = self.entry.data[CONF_CLOUDHOOK_URL]
|
||||
else:
|
||||
webhook_url = self.hass.components.webhook.async_generate_url(
|
||||
self.entry.data[CONF_WEBHOOK_ID]
|
||||
)
|
||||
|
||||
webhook_register(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
"Toon",
|
||||
self.entry.data[CONF_WEBHOOK_ID],
|
||||
self.handle_webhook,
|
||||
)
|
||||
|
||||
try:
|
||||
await self.toon.subscribe_webhook(
|
||||
application_id=self.entry.entry_id, url=webhook_url
|
||||
)
|
||||
_LOGGER.info("Registered Toon webhook: %s", webhook_url)
|
||||
except ToonError as err:
|
||||
_LOGGER.error("Error during webhook registration - %s", err)
|
||||
|
||||
self.hass.bus.async_listen_once(
|
||||
EVENT_HOMEASSISTANT_STOP, self.unregister_webhook
|
||||
)
|
||||
|
||||
async def handle_webhook(
|
||||
self, hass: HomeAssistant, webhook_id: str, request
|
||||
) -> None:
|
||||
"""Handle webhook callback."""
|
||||
try:
|
||||
data = await request.json()
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
_LOGGER.debug("Got webhook data: %s", data)
|
||||
|
||||
# Webhook expired notification, re-register
|
||||
if data.get("code") == 510:
|
||||
await self.register_webhook()
|
||||
return
|
||||
|
||||
if (
|
||||
"updateDataSet" not in data
|
||||
or "commonName" not in data
|
||||
or self.data.agreement.display_common_name != data["commonName"]
|
||||
):
|
||||
_LOGGER.warning("Received invalid data from Toon webhook - %s", data)
|
||||
return
|
||||
|
||||
try:
|
||||
await self.toon.update(data["updateDataSet"])
|
||||
self.update_listeners()
|
||||
except ToonError as err:
|
||||
_LOGGER.error("Could not process data received from Toon webhook - %s", err)
|
||||
|
||||
async def unregister_webhook(self, event: Optional[Event] = None) -> None:
|
||||
"""Remove / Unregister webhook for toon."""
|
||||
_LOGGER.debug(
|
||||
"Unregistering Toon webhook (%s)", self.entry.data[CONF_WEBHOOK_ID]
|
||||
)
|
||||
try:
|
||||
await self.toon.unsubscribe_webhook(self.entry.entry_id)
|
||||
except ToonError as err:
|
||||
_LOGGER.error("Failed unregistering Toon webhook - %s", err)
|
||||
|
||||
webhook_unregister(self.hass, self.entry.data[CONF_WEBHOOK_ID])
|
||||
|
||||
async def _async_update_data(self) -> Status:
|
||||
"""Fetch data from Toon."""
|
||||
try:
|
||||
return await self.toon.update()
|
||||
except ToonError as error:
|
||||
raise UpdateFailed(f"Invalid response from API: {error}")
|
|
@ -0,0 +1,29 @@
|
|||
"""Helpers for Toon."""
|
||||
import logging
|
||||
|
||||
from toonapi import ToonConnectionError, ToonError
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def toon_exception_handler(func):
|
||||
"""Decorate Toon calls to handle Toon exceptions.
|
||||
|
||||
A decorator that wraps the passed in function, catches Toon errors,
|
||||
and handles the availability of the device in the data coordinator.
|
||||
"""
|
||||
|
||||
async def handler(self, *args, **kwargs):
|
||||
try:
|
||||
await func(self, *args, **kwargs)
|
||||
self.coordinator.update_listeners()
|
||||
|
||||
except ToonConnectionError as error:
|
||||
_LOGGER.error("Error communicating with API: %s", error)
|
||||
self.coordinator.last_update_success = False
|
||||
self.coordinator.update_listeners()
|
||||
|
||||
except ToonError as error:
|
||||
_LOGGER.error("Invalid response from API: %s", error)
|
||||
|
||||
return handler
|
|
@ -3,6 +3,8 @@
|
|||
"name": "Toon",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/toon",
|
||||
"requirements": ["toonapilib==3.2.4"],
|
||||
"requirements": ["toonapi==0.1.0"],
|
||||
"dependencies": ["http"],
|
||||
"after_dependencies": ["cloud"],
|
||||
"codeowners": ["@frenck"]
|
||||
}
|
||||
|
|
|
@ -0,0 +1,153 @@
|
|||
"""DataUpdate Coordinator, and base Entity and Device models for Toon."""
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import ToonDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ToonEntity(Entity):
|
||||
"""Defines a base Toon entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ToonDataUpdateCoordinator,
|
||||
*,
|
||||
name: str,
|
||||
icon: str,
|
||||
enabled_default: bool = True,
|
||||
) -> None:
|
||||
"""Initialize the Toon entity."""
|
||||
self._enabled_default = enabled_default
|
||||
self._icon = icon
|
||||
self._name = name
|
||||
self._state = None
|
||||
self.coordinator = coordinator
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the name of the entity."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def icon(self) -> Optional[str]:
|
||||
"""Return the mdi icon of the entity."""
|
||||
return self._icon
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return self.coordinator.last_update_success
|
||||
|
||||
@property
|
||||
def entity_registry_enabled_default(self) -> bool:
|
||||
"""Return if the entity should be enabled when first added to the entity registry."""
|
||||
return self._enabled_default
|
||||
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
"""Return the polling requirement of the entity."""
|
||||
return False
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Connect to dispatcher listening for entity data notifications."""
|
||||
self.async_on_remove(
|
||||
self.coordinator.async_add_listener(self.async_write_ha_state)
|
||||
)
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update Toon entity."""
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
|
||||
class ToonDisplayDeviceEntity(ToonEntity):
|
||||
"""Defines a Toon display device entity."""
|
||||
|
||||
@property
|
||||
def device_info(self) -> Dict[str, Any]:
|
||||
"""Return device information about this thermostat."""
|
||||
agreement = self.coordinator.data.agreement
|
||||
model = agreement.display_hardware_version.rpartition("/")[0]
|
||||
sw_version = agreement.display_software_version.rpartition("/")[-1]
|
||||
return {
|
||||
"identifiers": {(DOMAIN, agreement.agreement_id)},
|
||||
"name": "Toon Display",
|
||||
"manufacturer": "Eneco",
|
||||
"model": model,
|
||||
"sw_version": sw_version,
|
||||
}
|
||||
|
||||
|
||||
class ToonElectricityMeterDeviceEntity(ToonEntity):
|
||||
"""Defines a Electricity Meter device entity."""
|
||||
|
||||
@property
|
||||
def device_info(self) -> Dict[str, Any]:
|
||||
"""Return device information about this entity."""
|
||||
agreement_id = self.coordinator.data.agreement.agreement_id
|
||||
return {
|
||||
"name": "Electricity Meter",
|
||||
"identifiers": {(DOMAIN, agreement_id, "electricity")},
|
||||
"via_device": (DOMAIN, agreement_id, "meter_adapter"),
|
||||
}
|
||||
|
||||
|
||||
class ToonGasMeterDeviceEntity(ToonEntity):
|
||||
"""Defines a Gas Meter device entity."""
|
||||
|
||||
@property
|
||||
def device_info(self) -> Dict[str, Any]:
|
||||
"""Return device information about this entity."""
|
||||
agreement_id = self.coordinator.data.agreement.agreement_id
|
||||
return {
|
||||
"name": "Gas Meter",
|
||||
"identifiers": {(DOMAIN, agreement_id, "gas")},
|
||||
"via_device": (DOMAIN, agreement_id, "electricity"),
|
||||
}
|
||||
|
||||
|
||||
class ToonSolarDeviceEntity(ToonEntity):
|
||||
"""Defines a Solar Device device entity."""
|
||||
|
||||
@property
|
||||
def device_info(self) -> Dict[str, Any]:
|
||||
"""Return device information about this entity."""
|
||||
agreement_id = self.coordinator.data.agreement.agreement_id
|
||||
return {
|
||||
"name": "Solar Panels",
|
||||
"identifiers": {(DOMAIN, agreement_id, "solar")},
|
||||
"via_device": (DOMAIN, agreement_id, "meter_adapter"),
|
||||
}
|
||||
|
||||
|
||||
class ToonBoilerModuleDeviceEntity(ToonEntity):
|
||||
"""Defines a Boiler Module device entity."""
|
||||
|
||||
@property
|
||||
def device_info(self) -> Dict[str, Any]:
|
||||
"""Return device information about this entity."""
|
||||
agreement_id = self.coordinator.data.agreement.agreement_id
|
||||
return {
|
||||
"name": "Boiler Module",
|
||||
"manufacturer": "Eneco",
|
||||
"identifiers": {(DOMAIN, agreement_id, "boiler_module")},
|
||||
"via_device": (DOMAIN, agreement_id),
|
||||
}
|
||||
|
||||
|
||||
class ToonBoilerDeviceEntity(ToonEntity):
|
||||
"""Defines a Boiler device entity."""
|
||||
|
||||
@property
|
||||
def device_info(self) -> Dict[str, Any]:
|
||||
"""Return device information about this entity."""
|
||||
agreement_id = self.coordinator.data.agreement.agreement_id
|
||||
return {
|
||||
"name": "Boiler",
|
||||
"identifiers": {(DOMAIN, agreement_id, "boiler")},
|
||||
"via_device": (DOMAIN, agreement_id, "boiler_module"),
|
||||
}
|
|
@ -0,0 +1,135 @@
|
|||
"""OAuth2 implementations for Toon."""
|
||||
import logging
|
||||
from typing import Any, Optional, cast
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from . import config_flow
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def register_oauth2_implementations(
|
||||
hass: HomeAssistant, client_id: str, client_secret: str
|
||||
) -> None:
|
||||
"""Register Toon OAuth2 implementations."""
|
||||
config_flow.ToonFlowHandler.async_register_implementation(
|
||||
hass,
|
||||
ToonLocalOAuth2Implementation(
|
||||
hass,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
name="Eneco Toon",
|
||||
tenant_id="eneco",
|
||||
issuer="identity.toon.eu",
|
||||
),
|
||||
)
|
||||
config_flow.ToonFlowHandler.async_register_implementation(
|
||||
hass,
|
||||
ToonLocalOAuth2Implementation(
|
||||
hass,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
name="Engie Electrabel Boxx",
|
||||
tenant_id="electrabel",
|
||||
),
|
||||
)
|
||||
config_flow.ToonFlowHandler.async_register_implementation(
|
||||
hass,
|
||||
ToonLocalOAuth2Implementation(
|
||||
hass,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
name="Viesgo",
|
||||
tenant_id="viesgo",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ToonLocalOAuth2Implementation(config_entry_oauth2_flow.LocalOAuth2Implementation):
|
||||
"""Local OAuth2 implementation for Toon."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
name: str,
|
||||
tenant_id: str,
|
||||
issuer: Optional[str] = None,
|
||||
):
|
||||
"""Local Toon Oauth Implementation."""
|
||||
self._name = name
|
||||
self.tenant_id = tenant_id
|
||||
self.issuer = issuer
|
||||
|
||||
super().__init__(
|
||||
hass=hass,
|
||||
domain=tenant_id,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
authorize_url="https://api.toon.eu/authorize",
|
||||
token_url="https://api.toon.eu/token",
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Name of the implementation."""
|
||||
return f"{self._name} via Configuration.yaml"
|
||||
|
||||
@property
|
||||
def extra_authorize_data(self) -> dict:
|
||||
"""Extra data that needs to be appended to the authorize url."""
|
||||
data = {"tenant_id": self.tenant_id}
|
||||
|
||||
if self.issuer is not None:
|
||||
data["issuer"] = self.issuer
|
||||
|
||||
return data
|
||||
|
||||
async def async_resolve_external_data(self, external_data: Any) -> dict:
|
||||
"""Initialize local Toon auth implementation."""
|
||||
data = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": external_data,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"tenant_id": self.tenant_id,
|
||||
}
|
||||
|
||||
if self.issuer is not None:
|
||||
data["issuer"] = self.issuer
|
||||
|
||||
return await self._token_request(data)
|
||||
|
||||
async def _async_refresh_token(self, token: dict) -> dict:
|
||||
"""Refresh tokens."""
|
||||
data = {
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": self.client_id,
|
||||
"refresh_token": token["refresh_token"],
|
||||
"tenant_id": self.tenant_id,
|
||||
}
|
||||
|
||||
new_token = await self._token_request(data)
|
||||
return {**token, **new_token}
|
||||
|
||||
async def _token_request(self, data: dict) -> dict:
|
||||
"""Make a token request."""
|
||||
session = async_get_clientsession(self.hass)
|
||||
headers = {}
|
||||
|
||||
data["client_id"] = self.client_id
|
||||
data["tenant_id"] = self.tenant_id
|
||||
|
||||
if self.client_secret is not None:
|
||||
data["client_secret"] = self.client_secret
|
||||
|
||||
if self.issuer is not None:
|
||||
data["issuer"] = self.issuer
|
||||
headers["issuer"] = self.issuer
|
||||
|
||||
resp = await session.post(self.token_url, data=data, headers=headers)
|
||||
resp.raise_for_status()
|
||||
return cast(dict, await resp.json())
|
|
@ -1,283 +1,136 @@
|
|||
"""Support for Toon sensors."""
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ENERGY_KILO_WATT_HOUR, POWER_WATT, UNIT_PERCENTAGE
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import (
|
||||
from .const import (
|
||||
ATTR_DEFAULT_ENABLED,
|
||||
ATTR_DEVICE_CLASS,
|
||||
ATTR_ICON,
|
||||
ATTR_MEASUREMENT,
|
||||
ATTR_NAME,
|
||||
ATTR_SECTION,
|
||||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
DOMAIN,
|
||||
SENSOR_ENTITIES,
|
||||
)
|
||||
from .coordinator import ToonDataUpdateCoordinator
|
||||
from .models import (
|
||||
ToonBoilerDeviceEntity,
|
||||
ToonData,
|
||||
ToonElectricityMeterDeviceEntity,
|
||||
ToonEntity,
|
||||
ToonGasMeterDeviceEntity,
|
||||
ToonSolarDeviceEntity,
|
||||
)
|
||||
from .const import CURRENCY_EUR, DATA_TOON, DOMAIN, VOLUME_CM3, VOLUME_M3
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistantType, entry: ConfigEntry, async_add_entities
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities
|
||||
) -> None:
|
||||
"""Set up Toon sensors based on a config entry."""
|
||||
toon = hass.data[DATA_TOON][entry.entry_id]
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
sensors = [
|
||||
ToonElectricityMeterDeviceSensor(
|
||||
toon, "power", "value", "Current Power Usage", "mdi:power-plug", POWER_WATT
|
||||
),
|
||||
ToonElectricityMeterDeviceSensor(
|
||||
toon,
|
||||
"power",
|
||||
"average",
|
||||
"Average Power Usage",
|
||||
"mdi:power-plug",
|
||||
POWER_WATT,
|
||||
),
|
||||
ToonElectricityMeterDeviceSensor(
|
||||
toon,
|
||||
"power",
|
||||
"daily_value",
|
||||
"Power Usage Today",
|
||||
"mdi:power-plug",
|
||||
ENERGY_KILO_WATT_HOUR,
|
||||
),
|
||||
ToonElectricityMeterDeviceSensor(
|
||||
toon,
|
||||
"power",
|
||||
"daily_cost",
|
||||
"Power Cost Today",
|
||||
"mdi:power-plug",
|
||||
CURRENCY_EUR,
|
||||
),
|
||||
ToonElectricityMeterDeviceSensor(
|
||||
toon,
|
||||
"power",
|
||||
"average_daily",
|
||||
"Average Daily Power Usage",
|
||||
"mdi:power-plug",
|
||||
ENERGY_KILO_WATT_HOUR,
|
||||
),
|
||||
ToonElectricityMeterDeviceSensor(
|
||||
toon,
|
||||
"power",
|
||||
"meter_reading",
|
||||
"Power Meter Feed IN Tariff 1",
|
||||
"mdi:power-plug",
|
||||
ENERGY_KILO_WATT_HOUR,
|
||||
),
|
||||
ToonElectricityMeterDeviceSensor(
|
||||
toon,
|
||||
"power",
|
||||
"meter_reading_low",
|
||||
"Power Meter Feed IN Tariff 2",
|
||||
"mdi:power-plug",
|
||||
ENERGY_KILO_WATT_HOUR,
|
||||
),
|
||||
ToonElectricityMeterDeviceSensor(coordinator, key=key)
|
||||
for key in (
|
||||
"power_average_daily",
|
||||
"power_average",
|
||||
"power_daily_cost",
|
||||
"power_daily_value",
|
||||
"power_meter_reading_low",
|
||||
"power_meter_reading",
|
||||
"power_value",
|
||||
"solar_meter_reading_low_produced",
|
||||
"solar_meter_reading_produced",
|
||||
)
|
||||
]
|
||||
|
||||
if toon.gas:
|
||||
if coordinator.data.gas_usage and coordinator.data.gas_usage.is_smart:
|
||||
sensors.extend(
|
||||
[
|
||||
ToonGasMeterDeviceSensor(
|
||||
toon,
|
||||
"gas",
|
||||
"value",
|
||||
"Current Gas Usage",
|
||||
"mdi:gas-cylinder",
|
||||
VOLUME_CM3,
|
||||
),
|
||||
ToonGasMeterDeviceSensor(
|
||||
toon,
|
||||
"gas",
|
||||
"average",
|
||||
"Average Gas Usage",
|
||||
"mdi:gas-cylinder",
|
||||
VOLUME_CM3,
|
||||
),
|
||||
ToonGasMeterDeviceSensor(
|
||||
toon,
|
||||
"gas",
|
||||
"daily_usage",
|
||||
"Gas Usage Today",
|
||||
"mdi:gas-cylinder",
|
||||
VOLUME_M3,
|
||||
),
|
||||
ToonGasMeterDeviceSensor(
|
||||
toon,
|
||||
"gas",
|
||||
"average_daily",
|
||||
"Average Daily Gas Usage",
|
||||
"mdi:gas-cylinder",
|
||||
VOLUME_M3,
|
||||
),
|
||||
ToonGasMeterDeviceSensor(
|
||||
toon,
|
||||
"gas",
|
||||
"meter_reading",
|
||||
"Gas Meter",
|
||||
"mdi:gas-cylinder",
|
||||
VOLUME_M3,
|
||||
),
|
||||
ToonGasMeterDeviceSensor(
|
||||
toon,
|
||||
"gas",
|
||||
"daily_cost",
|
||||
"Gas Cost Today",
|
||||
"mdi:gas-cylinder",
|
||||
CURRENCY_EUR,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
if toon.solar:
|
||||
sensors.extend(
|
||||
[
|
||||
ToonSolarDeviceSensor(
|
||||
toon,
|
||||
"solar",
|
||||
"value",
|
||||
"Current Solar Production",
|
||||
"mdi:solar-power",
|
||||
POWER_WATT,
|
||||
),
|
||||
ToonSolarDeviceSensor(
|
||||
toon,
|
||||
"solar",
|
||||
"maximum",
|
||||
"Max Solar Production",
|
||||
"mdi:solar-power",
|
||||
POWER_WATT,
|
||||
),
|
||||
ToonSolarDeviceSensor(
|
||||
toon,
|
||||
"solar",
|
||||
"produced",
|
||||
"Solar Production to Grid",
|
||||
"mdi:solar-power",
|
||||
POWER_WATT,
|
||||
),
|
||||
ToonSolarDeviceSensor(
|
||||
toon,
|
||||
"solar",
|
||||
"average_produced",
|
||||
"Average Solar Production to Grid",
|
||||
"mdi:solar-power",
|
||||
POWER_WATT,
|
||||
),
|
||||
ToonElectricityMeterDeviceSensor(
|
||||
toon,
|
||||
"solar",
|
||||
"meter_reading_produced",
|
||||
"Power Meter Feed OUT Tariff 1",
|
||||
"mdi:solar-power",
|
||||
ENERGY_KILO_WATT_HOUR,
|
||||
),
|
||||
ToonElectricityMeterDeviceSensor(
|
||||
toon,
|
||||
"solar",
|
||||
"meter_reading_low_produced",
|
||||
"Power Meter Feed OUT Tariff 2",
|
||||
"mdi:solar-power",
|
||||
ENERGY_KILO_WATT_HOUR,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
if toon.thermostat_info.have_ot_boiler:
|
||||
sensors.extend(
|
||||
[
|
||||
ToonBoilerDeviceSensor(
|
||||
toon,
|
||||
"thermostat_info",
|
||||
"current_modulation_level",
|
||||
"Boiler Modulation Level",
|
||||
"mdi:percent",
|
||||
UNIT_PERCENTAGE,
|
||||
ToonGasMeterDeviceSensor(coordinator, key=key)
|
||||
for key in (
|
||||
"gas_average_daily",
|
||||
"gas_average",
|
||||
"gas_daily_cost",
|
||||
"gas_daily_usage",
|
||||
"gas_meter_reading",
|
||||
"gas_value",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
if coordinator.data.agreement.is_toon_solar:
|
||||
sensors.extend(
|
||||
[
|
||||
ToonSolarDeviceSensor(coordinator, key=key)
|
||||
for key in [
|
||||
"solar_value",
|
||||
"solar_maximum",
|
||||
"solar_produced",
|
||||
"solar_average_produced",
|
||||
"power_usage_day_produced_solar",
|
||||
"power_usage_day_from_grid_usage",
|
||||
"power_usage_day_to_grid_usage",
|
||||
"power_usage_current_covered_by_solar",
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
if coordinator.data.thermostat.have_opentherm_boiler:
|
||||
sensors.extend(
|
||||
[
|
||||
ToonBoilerDeviceSensor(coordinator, key=key)
|
||||
for key in ["thermostat_info_current_modulation_level"]
|
||||
]
|
||||
)
|
||||
|
||||
async_add_entities(sensors, True)
|
||||
|
||||
|
||||
class ToonSensor(ToonEntity):
|
||||
"""Defines a Toon sensor."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
toon: ToonData,
|
||||
section: str,
|
||||
measurement: str,
|
||||
name: str,
|
||||
icon: str,
|
||||
unit_of_measurement: str,
|
||||
) -> None:
|
||||
def __init__(self, coordinator: ToonDataUpdateCoordinator, *, key: str) -> None:
|
||||
"""Initialize the Toon sensor."""
|
||||
self._state = None
|
||||
self._unit_of_measurement = unit_of_measurement
|
||||
self.section = section
|
||||
self.measurement = measurement
|
||||
self.key = key
|
||||
|
||||
super().__init__(toon, name, icon)
|
||||
super().__init__(
|
||||
coordinator,
|
||||
enabled_default=SENSOR_ENTITIES[key][ATTR_DEFAULT_ENABLED],
|
||||
icon=SENSOR_ENTITIES[key][ATTR_ICON],
|
||||
name=SENSOR_ENTITIES[key][ATTR_NAME],
|
||||
)
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Return the unique ID for this sensor."""
|
||||
return "_".join(
|
||||
[DOMAIN, self.toon.agreement.id, "sensor", self.section, self.measurement]
|
||||
)
|
||||
agreement_id = self.coordinator.data.agreement.agreement_id
|
||||
# This unique ID is a bit ugly and contains unneeded information.
|
||||
# It is here for legacy / backward compatible reasons.
|
||||
return f"{DOMAIN}_{agreement_id}_sensor_{self.key}"
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
def state(self) -> Optional[str]:
|
||||
"""Return the state of the sensor."""
|
||||
return self._state
|
||||
section = getattr(
|
||||
self.coordinator.data, SENSOR_ENTITIES[self.key][ATTR_SECTION]
|
||||
)
|
||||
return getattr(section, SENSOR_ENTITIES[self.key][ATTR_MEASUREMENT])
|
||||
|
||||
@property
|
||||
def unit_of_measurement(self) -> str:
|
||||
def unit_of_measurement(self) -> Optional[str]:
|
||||
"""Return the unit this state is expressed in."""
|
||||
return self._unit_of_measurement
|
||||
return SENSOR_ENTITIES[self.key][ATTR_UNIT_OF_MEASUREMENT]
|
||||
|
||||
def update(self) -> None:
|
||||
"""Get the latest data from the sensor."""
|
||||
section = getattr(self.toon, self.section)
|
||||
value = None
|
||||
|
||||
if not section:
|
||||
return
|
||||
|
||||
if self.section == "power" and self.measurement == "daily_value":
|
||||
value = round(
|
||||
(float(section.daily_usage) + float(section.daily_usage_low)) / 1000.0,
|
||||
2,
|
||||
)
|
||||
|
||||
if value is None:
|
||||
value = getattr(section, self.measurement)
|
||||
|
||||
if self.section == "power" and self.measurement in [
|
||||
"meter_reading",
|
||||
"meter_reading_low",
|
||||
"average_daily",
|
||||
]:
|
||||
value = round(float(value) / 1000.0, 2)
|
||||
|
||||
if self.section == "solar" and self.measurement in [
|
||||
"meter_reading_produced",
|
||||
"meter_reading_low_produced",
|
||||
]:
|
||||
value = float(value) / 1000.0
|
||||
|
||||
if self.section == "gas" and self.measurement in [
|
||||
"average_daily",
|
||||
"daily_usage",
|
||||
"meter_reading",
|
||||
]:
|
||||
value = round(float(value) / 1000.0, 2)
|
||||
|
||||
self._state = max(0, value)
|
||||
@property
|
||||
def device_class(self) -> Optional[str]:
|
||||
"""Return the device class."""
|
||||
return SENSOR_ENTITIES[self.key][ATTR_DEVICE_CLASS]
|
||||
|
||||
|
||||
class ToonElectricityMeterDeviceSensor(ToonSensor, ToonElectricityMeterDeviceEntity):
|
||||
|
|
|
@ -0,0 +1,121 @@
|
|||
"""Support for Toon switches."""
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from toonapi import (
|
||||
ACTIVE_STATE_AWAY,
|
||||
ACTIVE_STATE_HOLIDAY,
|
||||
PROGRAM_STATE_OFF,
|
||||
PROGRAM_STATE_ON,
|
||||
)
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
|
||||
from .const import (
|
||||
ATTR_DEFAULT_ENABLED,
|
||||
ATTR_ICON,
|
||||
ATTR_INVERTED,
|
||||
ATTR_MEASUREMENT,
|
||||
ATTR_NAME,
|
||||
ATTR_SECTION,
|
||||
DOMAIN,
|
||||
SWITCH_ENTITIES,
|
||||
)
|
||||
from .coordinator import ToonDataUpdateCoordinator
|
||||
from .helpers import toon_exception_handler
|
||||
from .models import ToonDisplayDeviceEntity, ToonEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistantType, entry: ConfigEntry, async_add_entities
|
||||
) -> None:
|
||||
"""Set up a Toon switches based on a config entry."""
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
async_add_entities(
|
||||
[ToonProgramSwitch(coordinator), ToonHolidayModeSwitch(coordinator)]
|
||||
)
|
||||
|
||||
|
||||
class ToonSwitch(ToonEntity, SwitchEntity):
|
||||
"""Defines an Toon switch."""
|
||||
|
||||
def __init__(self, coordinator: ToonDataUpdateCoordinator, *, key: str) -> None:
|
||||
"""Initialize the Toon switch."""
|
||||
self.key = key
|
||||
|
||||
super().__init__(
|
||||
coordinator,
|
||||
enabled_default=SWITCH_ENTITIES[key][ATTR_DEFAULT_ENABLED],
|
||||
icon=SWITCH_ENTITIES[key][ATTR_ICON],
|
||||
name=SWITCH_ENTITIES[key][ATTR_NAME],
|
||||
)
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Return the unique ID for this binary sensor."""
|
||||
agreement_id = self.coordinator.data.agreement.agreement_id
|
||||
# This unique ID is a bit ugly and contains unneeded information.
|
||||
# It is here for legacy / backward compatible reasons.
|
||||
return f"{DOMAIN}_{agreement_id}_switch_{self.key}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return the status of the binary sensor."""
|
||||
section = getattr(
|
||||
self.coordinator.data, SWITCH_ENTITIES[self.key][ATTR_SECTION]
|
||||
)
|
||||
value = getattr(section, SWITCH_ENTITIES[self.key][ATTR_MEASUREMENT])
|
||||
|
||||
if SWITCH_ENTITIES[self.key][ATTR_INVERTED]:
|
||||
return not value
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class ToonProgramSwitch(ToonSwitch, ToonDisplayDeviceEntity):
|
||||
"""Defines a Toon program switch."""
|
||||
|
||||
def __init__(self, coordinator: ToonDataUpdateCoordinator) -> None:
|
||||
"""Initialize the Toon program switch."""
|
||||
super().__init__(coordinator, key="thermostat_program")
|
||||
|
||||
@toon_exception_handler
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the Toon program switch."""
|
||||
await self.coordinator.toon.set_active_state(
|
||||
ACTIVE_STATE_AWAY, PROGRAM_STATE_OFF
|
||||
)
|
||||
|
||||
@toon_exception_handler
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the Toon program switch."""
|
||||
await self.coordinator.toon.set_active_state(
|
||||
ACTIVE_STATE_AWAY, PROGRAM_STATE_ON
|
||||
)
|
||||
|
||||
|
||||
class ToonHolidayModeSwitch(ToonSwitch, ToonDisplayDeviceEntity):
|
||||
"""Defines a Toon Holiday mode switch."""
|
||||
|
||||
def __init__(self, coordinator: ToonDataUpdateCoordinator) -> None:
|
||||
"""Initialize the Toon holiday switch."""
|
||||
super().__init__(coordinator, key="thermostat_holiday_mode")
|
||||
|
||||
@toon_exception_handler
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the Toon holiday mode switch."""
|
||||
await self.coordinator.toon.set_active_state(
|
||||
ACTIVE_STATE_AWAY, PROGRAM_STATE_ON
|
||||
)
|
||||
|
||||
@toon_exception_handler
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the Toon holiday mode switch."""
|
||||
await self.coordinator.toon.set_active_state(
|
||||
ACTIVE_STATE_HOLIDAY, PROGRAM_STATE_OFF
|
||||
)
|
|
@ -120,10 +120,16 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation):
|
|||
"""Return the redirect uri."""
|
||||
return f"{get_url(self.hass)}{AUTH_CALLBACK_PATH}"
|
||||
|
||||
@property
|
||||
def extra_authorize_data(self) -> dict:
|
||||
"""Extra data that needs to be appended to the authorize url."""
|
||||
return {}
|
||||
|
||||
async def async_generate_authorize_url(self, flow_id: str) -> str:
|
||||
"""Generate a url for the user to authorize."""
|
||||
return str(
|
||||
URL(self.authorize_url).with_query(
|
||||
URL(self.authorize_url)
|
||||
.with_query(
|
||||
{
|
||||
"response_type": "code",
|
||||
"client_id": self.client_id,
|
||||
|
@ -131,6 +137,7 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation):
|
|||
"state": _encode_jwt(self.hass, {"flow_id": flow_id}),
|
||||
}
|
||||
)
|
||||
.update_query(self.extra_authorize_data)
|
||||
)
|
||||
|
||||
async def async_resolve_external_data(self, external_data: Any) -> dict:
|
||||
|
|
|
@ -2118,7 +2118,7 @@ tmb==0.0.4
|
|||
todoist-python==8.0.0
|
||||
|
||||
# homeassistant.components.toon
|
||||
toonapilib==3.2.4
|
||||
toonapi==0.1.0
|
||||
|
||||
# homeassistant.components.totalconnect
|
||||
total_connect_client==0.55
|
||||
|
|
|
@ -890,7 +890,7 @@ tesla-powerwall==0.2.11
|
|||
teslajsonpy==0.8.1
|
||||
|
||||
# homeassistant.components.toon
|
||||
toonapilib==3.2.4
|
||||
toonapi==0.1.0
|
||||
|
||||
# homeassistant.components.totalconnect
|
||||
total_connect_client==0.55
|
||||
|
|
|
@ -1,182 +1,290 @@
|
|||
"""Tests for the Toon config flow."""
|
||||
|
||||
import pytest
|
||||
from toonapilib.toonapilibexceptions import (
|
||||
AgreementsRetrievalError,
|
||||
InvalidConsumerKey,
|
||||
InvalidConsumerSecret,
|
||||
InvalidCredentials,
|
||||
)
|
||||
from toonapi import Agreement, ToonError
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.toon import config_flow
|
||||
from homeassistant.components.toon.const import CONF_DISPLAY, CONF_TENANT, DOMAIN
|
||||
from homeassistant.const import (
|
||||
CONF_CLIENT_ID,
|
||||
CONF_CLIENT_SECRET,
|
||||
CONF_PASSWORD,
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.components.toon.const import CONF_AGREEMENT, CONF_MIGRATE, DOMAIN
|
||||
from homeassistant.config import async_process_ha_core_config
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER
|
||||
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.async_mock import patch
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
FIXTURE_APP = {
|
||||
DOMAIN: {CONF_CLIENT_ID: "1234567890abcdef", CONF_CLIENT_SECRET: "1234567890abcdef"}
|
||||
}
|
||||
|
||||
FIXTURE_CREDENTIALS = {
|
||||
CONF_USERNAME: "john.doe",
|
||||
CONF_PASSWORD: "secret",
|
||||
CONF_TENANT: "eneco",
|
||||
}
|
||||
|
||||
FIXTURE_DISPLAY = {CONF_DISPLAY: "display1"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_toonapilib():
|
||||
"""Mock toonapilib."""
|
||||
with patch("homeassistant.components.toon.config_flow.Toon") as Toon:
|
||||
Toon().display_names = [FIXTURE_DISPLAY[CONF_DISPLAY]]
|
||||
yield Toon
|
||||
|
||||
|
||||
async def setup_component(hass):
|
||||
"""Set up Toon component."""
|
||||
await async_process_ha_core_config(
|
||||
hass, {"external_url": "https://example.com"},
|
||||
)
|
||||
|
||||
with patch("os.path.isfile", return_value=False):
|
||||
assert await async_setup_component(hass, DOMAIN, FIXTURE_APP)
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
DOMAIN,
|
||||
{DOMAIN: {CONF_CLIENT_ID: "client", CONF_CLIENT_SECRET: "secret"}},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
async def test_abort_if_no_app_configured(hass):
|
||||
async def test_abort_if_no_configuration(hass):
|
||||
"""Test abort if no app is configured."""
|
||||
flow = config_flow.ToonFlowHandler()
|
||||
flow.hass = hass
|
||||
result = await flow.async_step_user()
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||
assert result["reason"] == "no_app"
|
||||
assert result["reason"] == "missing_configuration"
|
||||
|
||||
|
||||
async def test_show_authenticate_form(hass):
|
||||
"""Test that the authentication form is served."""
|
||||
await setup_component(hass)
|
||||
|
||||
flow = config_flow.ToonFlowHandler()
|
||||
flow.hass = hass
|
||||
result = await flow.async_step_user(user_input=None)
|
||||
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||
assert result["step_id"] == "authenticate"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"side_effect,reason",
|
||||
[
|
||||
(InvalidConsumerKey, CONF_CLIENT_ID),
|
||||
(InvalidConsumerSecret, CONF_CLIENT_SECRET),
|
||||
(AgreementsRetrievalError, "no_agreements"),
|
||||
(Exception, "unknown_auth_fail"),
|
||||
],
|
||||
)
|
||||
async def test_toon_abort(hass, mock_toonapilib, side_effect, reason):
|
||||
"""Test we abort on Toon error."""
|
||||
await setup_component(hass)
|
||||
|
||||
flow = config_flow.ToonFlowHandler()
|
||||
flow.hass = hass
|
||||
|
||||
mock_toonapilib.side_effect = side_effect
|
||||
|
||||
result = await flow.async_step_authenticate(user_input=FIXTURE_CREDENTIALS)
|
||||
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||
assert result["reason"] == reason
|
||||
|
||||
|
||||
async def test_invalid_credentials(hass, mock_toonapilib):
|
||||
"""Test we show authentication form on Toon auth error."""
|
||||
mock_toonapilib.side_effect = InvalidCredentials
|
||||
|
||||
await setup_component(hass)
|
||||
|
||||
flow = config_flow.ToonFlowHandler()
|
||||
flow.hass = hass
|
||||
result = await flow.async_step_user(user_input=FIXTURE_CREDENTIALS)
|
||||
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||
assert result["step_id"] == "authenticate"
|
||||
assert result["errors"] == {"base": "credentials"}
|
||||
|
||||
|
||||
async def test_full_flow_implementation(hass, mock_toonapilib):
|
||||
async def test_full_flow_implementation(hass, aiohttp_client, aioclient_mock):
|
||||
"""Test registering an integration and finishing flow works."""
|
||||
await setup_component(hass)
|
||||
|
||||
flow = config_flow.ToonFlowHandler()
|
||||
flow.hass = hass
|
||||
result = await flow.async_step_user(user_input=None)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||
assert result["step_id"] == "authenticate"
|
||||
assert result["step_id"] == "pick_implementation"
|
||||
|
||||
result = await flow.async_step_user(user_input=FIXTURE_CREDENTIALS)
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||
assert result["step_id"] == "display"
|
||||
# pylint: disable=protected-access
|
||||
state = config_entry_oauth2_flow._encode_jwt(hass, {"flow_id": result["flow_id"]})
|
||||
|
||||
result = await flow.async_step_display(user_input=FIXTURE_DISPLAY)
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||
assert result["title"] == FIXTURE_DISPLAY[CONF_DISPLAY]
|
||||
assert result["data"][CONF_USERNAME] == FIXTURE_CREDENTIALS[CONF_USERNAME]
|
||||
assert result["data"][CONF_PASSWORD] == FIXTURE_CREDENTIALS[CONF_PASSWORD]
|
||||
assert result["data"][CONF_TENANT] == FIXTURE_CREDENTIALS[CONF_TENANT]
|
||||
assert result["data"][CONF_DISPLAY] == FIXTURE_DISPLAY[CONF_DISPLAY]
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"implementation": "eneco"}
|
||||
)
|
||||
|
||||
assert result2["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP
|
||||
assert result2["url"] == (
|
||||
"https://api.toon.eu/authorize"
|
||||
"?response_type=code&client_id=client"
|
||||
"&redirect_uri=https://example.com/auth/external/callback"
|
||||
f"&state={state}"
|
||||
"&tenant_id=eneco&issuer=identity.toon.eu"
|
||||
)
|
||||
|
||||
client = await aiohttp_client(hass.http.app)
|
||||
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||
assert resp.status == 200
|
||||
assert resp.headers["content-type"] == "text/html; charset=utf-8"
|
||||
|
||||
aioclient_mock.post(
|
||||
"https://api.toon.eu/token",
|
||||
json={
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"access_token": "mock-access-token",
|
||||
"type": "Bearer",
|
||||
"expires_in": 60,
|
||||
},
|
||||
)
|
||||
|
||||
with patch("toonapi.Toon.agreements", return_value=[Agreement(agreement_id=123)]):
|
||||
result3 = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
|
||||
assert result3["data"]["auth_implementation"] == "eneco"
|
||||
assert result3["data"]["agreement_id"] == 123
|
||||
result3["data"]["token"].pop("expires_at")
|
||||
assert result3["data"]["token"] == {
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"access_token": "mock-access-token",
|
||||
"type": "Bearer",
|
||||
"expires_in": 60,
|
||||
}
|
||||
|
||||
|
||||
async def test_no_displays(hass, mock_toonapilib):
|
||||
async def test_no_agreements(hass, aiohttp_client, aioclient_mock):
|
||||
"""Test abort when there are no displays."""
|
||||
await setup_component(hass)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
mock_toonapilib().display_names = []
|
||||
# pylint: disable=protected-access
|
||||
state = config_entry_oauth2_flow._encode_jwt(hass, {"flow_id": result["flow_id"]})
|
||||
await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"implementation": "eneco"}
|
||||
)
|
||||
|
||||
flow = config_flow.ToonFlowHandler()
|
||||
flow.hass = hass
|
||||
await flow.async_step_user(user_input=FIXTURE_CREDENTIALS)
|
||||
client = await aiohttp_client(hass.http.app)
|
||||
await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||
aioclient_mock.post(
|
||||
"https://api.toon.eu/token",
|
||||
json={
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"access_token": "mock-access-token",
|
||||
"type": "Bearer",
|
||||
"expires_in": 60,
|
||||
},
|
||||
)
|
||||
|
||||
result = await flow.async_step_display(user_input=None)
|
||||
with patch("toonapi.Toon.agreements", return_value=[]):
|
||||
result3 = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||
assert result["reason"] == "no_displays"
|
||||
assert result3["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||
assert result3["reason"] == "no_agreements"
|
||||
|
||||
|
||||
async def test_display_already_exists(hass, mock_toonapilib):
|
||||
async def test_multiple_agreements(hass, aiohttp_client, aioclient_mock):
|
||||
"""Test abort when there are no displays."""
|
||||
await setup_component(hass)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
# pylint: disable=protected-access
|
||||
state = config_entry_oauth2_flow._encode_jwt(hass, {"flow_id": result["flow_id"]})
|
||||
await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"implementation": "eneco"}
|
||||
)
|
||||
|
||||
client = await aiohttp_client(hass.http.app)
|
||||
await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||
|
||||
aioclient_mock.post(
|
||||
"https://api.toon.eu/token",
|
||||
json={
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"access_token": "mock-access-token",
|
||||
"type": "Bearer",
|
||||
"expires_in": 60,
|
||||
},
|
||||
)
|
||||
|
||||
with patch(
|
||||
"toonapi.Toon.agreements",
|
||||
return_value=[Agreement(agreement_id=1), Agreement(agreement_id=2)],
|
||||
):
|
||||
result3 = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
|
||||
assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||
assert result3["step_id"] == "agreement"
|
||||
|
||||
result4 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_AGREEMENT: "None None, None"}
|
||||
)
|
||||
assert result4["data"]["auth_implementation"] == "eneco"
|
||||
assert result4["data"]["agreement_id"] == 1
|
||||
|
||||
|
||||
async def test_agreement_already_set_up(hass, aiohttp_client, aioclient_mock):
|
||||
"""Test showing display form again if display already exists."""
|
||||
await setup_component(hass)
|
||||
MockConfigEntry(domain=DOMAIN, unique_id=123).add_to_hass(hass)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
flow = config_flow.ToonFlowHandler()
|
||||
flow.hass = hass
|
||||
await flow.async_step_user(user_input=FIXTURE_CREDENTIALS)
|
||||
# pylint: disable=protected-access
|
||||
state = config_entry_oauth2_flow._encode_jwt(hass, {"flow_id": result["flow_id"]})
|
||||
await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"implementation": "eneco"}
|
||||
)
|
||||
|
||||
MockConfigEntry(domain=DOMAIN, data=FIXTURE_DISPLAY).add_to_hass(hass)
|
||||
client = await aiohttp_client(hass.http.app)
|
||||
await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||
aioclient_mock.post(
|
||||
"https://api.toon.eu/token",
|
||||
json={
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"access_token": "mock-access-token",
|
||||
"type": "Bearer",
|
||||
"expires_in": 60,
|
||||
},
|
||||
)
|
||||
|
||||
result = await flow.async_step_display(user_input=FIXTURE_DISPLAY)
|
||||
with patch("toonapi.Toon.agreements", return_value=[Agreement(agreement_id=123)]):
|
||||
result3 = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||
assert result["step_id"] == "display"
|
||||
assert result["errors"] == {"base": "display_exists"}
|
||||
assert result3["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||
assert result3["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_abort_last_minute_fail(hass, mock_toonapilib):
|
||||
"""Test we abort when API communication fails in the last step."""
|
||||
async def test_toon_abort(hass, aiohttp_client, aioclient_mock):
|
||||
"""Test we abort on Toon error."""
|
||||
await setup_component(hass)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
# pylint: disable=protected-access
|
||||
state = config_entry_oauth2_flow._encode_jwt(hass, {"flow_id": result["flow_id"]})
|
||||
await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"implementation": "eneco"}
|
||||
)
|
||||
|
||||
client = await aiohttp_client(hass.http.app)
|
||||
await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||
aioclient_mock.post(
|
||||
"https://api.toon.eu/token",
|
||||
json={
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"access_token": "mock-access-token",
|
||||
"type": "Bearer",
|
||||
"expires_in": 60,
|
||||
},
|
||||
)
|
||||
|
||||
with patch("toonapi.Toon.agreements", side_effect=ToonError):
|
||||
result2 = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
|
||||
assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||
assert result2["reason"] == "connection_error"
|
||||
|
||||
|
||||
async def test_import(hass):
|
||||
"""Test if importing step works."""
|
||||
await setup_component(hass)
|
||||
|
||||
flow = config_flow.ToonFlowHandler()
|
||||
flow.hass = hass
|
||||
await flow.async_step_user(user_input=FIXTURE_CREDENTIALS)
|
||||
# Setting up the component without entries, should already have triggered
|
||||
# it. Hence, expect this to throw an already_in_progress.
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_IMPORT}
|
||||
)
|
||||
|
||||
mock_toonapilib.side_effect = Exception
|
||||
|
||||
result = await flow.async_step_display(user_input=FIXTURE_DISPLAY)
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||
assert result["reason"] == "unknown_auth_fail"
|
||||
assert result["reason"] == "already_in_progress"
|
||||
|
||||
|
||||
async def test_import_migration(hass, aiohttp_client, aioclient_mock):
|
||||
"""Test if importing step with migration works."""
|
||||
old_entry = MockConfigEntry(domain=DOMAIN, unique_id=123, version=1)
|
||||
old_entry.add_to_hass(hass)
|
||||
|
||||
await setup_component(hass)
|
||||
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
assert len(entries) == 1
|
||||
assert entries[0].version == 1
|
||||
|
||||
flows = hass.config_entries.flow.async_progress()
|
||||
assert len(flows) == 1
|
||||
assert flows[0]["context"][CONF_MIGRATE] == old_entry.entry_id
|
||||
|
||||
# pylint: disable=protected-access
|
||||
state = config_entry_oauth2_flow._encode_jwt(hass, {"flow_id": flows[0]["flow_id"]})
|
||||
await hass.config_entries.flow.async_configure(
|
||||
flows[0]["flow_id"], {"implementation": "eneco"}
|
||||
)
|
||||
|
||||
client = await aiohttp_client(hass.http.app)
|
||||
await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||
aioclient_mock.post(
|
||||
"https://api.toon.eu/token",
|
||||
json={
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"access_token": "mock-access-token",
|
||||
"type": "Bearer",
|
||||
"expires_in": 60,
|
||||
},
|
||||
)
|
||||
|
||||
with patch("toonapi.Toon.agreements", return_value=[Agreement(agreement_id=123)]):
|
||||
result = await hass.config_entries.flow.async_configure(flows[0]["flow_id"])
|
||||
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
assert len(entries) == 1
|
||||
assert entries[0].version == 2
|
||||
|
|
|
@ -69,6 +69,11 @@ class MockOAuth2Implementation(config_entry_oauth2_flow.AbstractOAuth2Implementa
|
|||
"""Domain that is providing the implementation."""
|
||||
return "test"
|
||||
|
||||
@property
|
||||
def extra_authorize_data(self) -> dict:
|
||||
"""Extra data that needs to be appended to the authorize url."""
|
||||
return {"extra": "data"}
|
||||
|
||||
async def async_generate_authorize_url(self, flow_id: str) -> str:
|
||||
"""Generate a url for the user to authorize."""
|
||||
return "http://example.com/auth"
|
||||
|
|
Loading…
Reference in New Issue